diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d9405b8..0356497 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -26,6 +26,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -766,6 +767,19 @@ dependencies = [ "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]] name = "cssparser" version = "0.36.0" @@ -1081,6 +1095,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ego-tree" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12a0bb14ac04a9fcf170d0bbbef949b44cc492f4452bd20c095636956f653642" + [[package]] name = "either" version = "1.15.0" @@ -1568,6 +1588,15 @@ dependencies = [ "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]] name = "getrandom" version = "0.1.16" @@ -1880,6 +1909,20 @@ dependencies = [ "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]] name = "html5ever" version = "0.29.1" @@ -2494,6 +2537,20 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "markup5ever" version = "0.14.1" @@ -3092,6 +3149,16 @@ dependencies = [ "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]] name = "phf_codegen" version = "0.11.3" @@ -4093,6 +4160,22 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "seahash" version = "4.1.0" @@ -4140,6 +4223,25 @@ dependencies = [ "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]] name = "selectors" version = "0.36.1" @@ -4339,6 +4441,15 @@ dependencies = [ "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]] name = "servo_arc" version = "0.4.3" @@ -5592,6 +5703,7 @@ dependencies = [ "gtk", "reqwest 0.12.28", "rusqlite", + "scraper", "serde", "serde_json", "tauri", @@ -5806,6 +5918,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 05432a4..2e69a45 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,6 +26,7 @@ tokio = { version = "1", features = ["full"] } chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4"] } dirs-next = "2" +scraper = "0.20" [target.'cfg(target_os = "linux")'.dependencies] webkit2gtk = { version = "2.0", features = ["v2_38"] } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index fc590a3..8512f31 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -173,6 +173,18 @@ pub fn has_guides(state: State) -> Result { Ok(!guides.is_empty()) } +#[tauri::command] +pub fn get_resource_inventory(state: State, profile_id: String) -> Result, 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, 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] pub fn get_setting(state: State, key: String) -> Result, 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, + pub launch_position: Option, +} + +#[tauri::command] +pub async fn fetch_quest_detail(url: String) -> Result, 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, 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 = 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 { + 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 is a sibling of (font's grandparent), + // not a sibling of 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 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 { + 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, + profile_id: String, + quest_name: String, +) -> Result, 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, + profile_id: String, + quest_name: String, + step_index: i64, +) -> Result { + 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 { let mut names = Vec::new(); for section in &data.sections { diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 5ea98fe..065b05b 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -60,6 +60,22 @@ pub fn migrate(conn: &Connection) -> Result<()> { key TEXT PRIMARY KEY, 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(()) } @@ -145,6 +161,62 @@ pub fn get_guides(conn: &Connection) -> Result> { rows.collect() } +pub fn get_completed_steps(conn: &Connection, profile_id: &str, quest_name: &str) -> Result> { + 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 { + 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> { + 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 { conn.query_row( "SELECT value FROM settings WHERE key = ?1", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4e3f25f..49a6c29 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -48,6 +48,11 @@ pub fn run() { commands::get_setting, commands::set_setting, 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!()) .expect("error while running tauri application"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 99a9b81..c7cc83e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -15,7 +15,7 @@ "title": "TougliGui", "width": 1100, "height": 720, - "minWidth": 380, + "minWidth": 300, "minHeight": 400, "decorations": false, "transparent": false, diff --git a/src/App.tsx b/src/App.tsx index f78d07e..4a16fd7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,29 +1,64 @@ import { useEffect, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; +import { getCurrentWindow, LogicalSize } from "@tauri-apps/api/window"; import { useStore } from "./store"; import TitleBar from "./components/TitleBar"; -import Sidebar from "./components/Sidebar"; import HomeView from "./components/HomeView"; import GuideView from "./components/GuideView"; -import ProfileModal from "./components/ProfileModal"; +import SettingsPanel from "./components/SettingsPanel"; import SyncOverlay from "./components/SyncOverlay"; export default function App() { - const { loadProfiles, loadGuides, view, syncing, syncGuides } = useStore(); - const [showProfileModal, setShowProfileModal] = useState(false); + const { loadProfiles, loadGuides, openGuide, setResourcesPanelCollapsed, view, syncing, syncGuides } = useStore(); + const [showSettings, setShowSettings] = useState(false); const [needsSync, setNeedsSync] = useState(false); useEffect(() => { async function init() { + // Restore window size + const [savedW, savedH] = await Promise.all([ + invoke("get_setting", { key: "window_width" }), + invoke("get_setting", { key: "window_height" }), + ]); + if (savedW && savedH) { + await getCurrentWindow().setSize(new LogicalSize(parseInt(savedW), parseInt(savedH))); + } + await loadProfiles(); + const has = await invoke("has_guides"); if (!has) { setNeedsSync(true); } else { await loadGuides(); + // Restore last viewed guide + const lastGuide = await invoke("get_setting", { key: "active_guide" }); + if (lastGuide) { + try { + await openGuide(lastGuide); + setResourcesPanelCollapsed(true); + } catch { /* guide may no longer exist */ } + } } } init(); + + // Persist window size on resize (debounced) + const win = getCurrentWindow(); + let debounce: ReturnType | 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() { @@ -33,16 +68,15 @@ export default function App() { return (
- setShowProfileModal(true)} /> + setShowSettings(s => !s)} />
-
{view === "home" ? : }
- {showProfileModal && setShowProfileModal(false)} />} - {syncing && } + {showSettings && setShowSettings(false)} />} + {syncing && !showSettings && } {needsSync && (
{ + const handler = () => setWidth(window.innerWidth); + window.addEventListener("resize", handler); + return () => window.removeEventListener("resize", handler); + }, []); + return width; +} function combatIcon(name: string): string { const l = name.toLowerCase(); - if (l.includes("solo")) return "⚔️"; - if (l.includes("group") || l.includes("groupe")) return "👥"; - if (l.includes("boss")) return "💀"; - if (l.includes("arène") || l.includes("arene")) return "🏟️"; - return "⚔️"; + if (l.includes("solo") || l.includes("seul")) return "🗡️"; + if (l.includes("group") || l.includes("groupe")) return "⚔️"; + if (l.includes("donjon") || l.includes("boss")) return "💀"; + return "🗡️"; } export default function GuideView() { - const { activeGuideData, completedQuests, toggleQuest } = useStore(); - const [resourcesCollapsed, setResourcesCollapsed] = useState(false); + const { activeGuideData, completedQuests, toggleQuest, activeProfileId, resourcesPanelCollapsed, setResourcesPanelCollapsed, resourceInventory, setResourceQuantity } = useStore(); + 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 (selectedQuest && activeProfileId) { + return ( +
+ setSelectedQuest(null)} + /> +
+ ); + } + const { name, effect, recommended_level, resources, sections, combat_legend } = activeGuideData; const allQuests = collectAllQuests(sections); @@ -26,7 +54,7 @@ export default function GuideView() { const isDone = pct === 100; return ( -
+
{/* Header */} @@ -87,7 +115,7 @@ export default function GuideView() { {section.name} {section.items.map((item, ii) => ( - + ))}
))} @@ -96,25 +124,41 @@ export default function GuideView() { {/* Resources panel */} {resources.length > 0 && (
{/* Toggle */}
@@ -179,10 +257,11 @@ function Legend({ legend }: { legend: CombatType[] }) { ); } -function SectionItemView({ item, completedQuests, onToggle }: { +function SectionItemView({ item, completedQuests, onToggle, onSelect }: { item: SectionItem; completedQuests: Set; onToggle: (name: string) => void; + onSelect: (quest: { name: string; url: string | null }) => void; }) { if (item.type === "Instruction") { if (item.text.startsWith("__ZONE__:")) { @@ -209,7 +288,7 @@ function SectionItemView({ item, completedQuests, onToggle }: { Rappel - {item.text} +
); } @@ -226,32 +305,32 @@ function SectionItemView({ item, completedQuests, onToggle }: {
)} {item.quests.map((q, i) => ( - + ))}
); } if (item.type === "Quest") { - return ; + return ; } return null; } -function QuestRow({ quest, completed, onToggle, indent }: { +function QuestRow({ quest, completed, onToggle, onSelect, indent }: { quest: QuestItem; completed: boolean; onToggle: (name: string) => void; + onSelect: (quest: { name: string; url: string | null }) => void; indent?: boolean; }) { return (
onToggle(quest.name)} style={{ display: "flex", alignItems: "flex-start", gap: "8px", padding: indent ? "3px 0" : "4px 6px", - borderRadius: "5px", cursor: "pointer", + borderRadius: "5px", marginBottom: indent ? "1px" : "2px", opacity: completed ? 0.5 : 1, transition: "all 0.12s", @@ -263,21 +342,19 @@ function QuestRow({ quest, completed, onToggle, indent }: { type="checkbox" checked={completed} onChange={() => onToggle(quest.name)} - onClick={e => e.stopPropagation()} - style={{ marginTop: "2px", flexShrink: 0 }} + style={{ marginTop: "2px", flexShrink: 0, cursor: "pointer" }} />
- { - if (quest.url) { e.stopPropagation(); openUrl(quest.url); } + onSelect({ name: quest.name, url: quest.url })} + style={{ + fontSize: "12px", lineHeight: 1.4, + color: completed ? "#4a5568" : "#93c5fd", + textDecoration: completed ? "line-through" : "underline", + textDecorationColor: "rgba(147,197,253,0.3)", + cursor: "pointer", + wordBreak: "break-word", }} > {quest.name} @@ -290,7 +367,7 @@ function QuestRow({ quest, completed, onToggle, indent }: {
{quest.note && (
- → {quest.note} + →
)}
diff --git a/src/components/QuestDetailPanel.tsx b/src/components/QuestDetailPanel.tsx new file mode 100644 index 0000000..51fd3d9 --- /dev/null +++ b/src/components/QuestDetailPanel.tsx @@ -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([]); + const [completedSteps, setCompletedSteps] = useState>(new Set()); + const [expandedSteps, setExpandedSteps] = useState>(new Set()); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!questUrl) { + setError("Aucun lien disponible pour cette quête."); + setLoading(false); + return; + } + + setLoading(true); + setError(null); + + Promise.all([ + invoke("fetch_quest_detail", { url: questUrl }), + invoke("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("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 ( +
+ {/* Title bar */} +
+
+ + + {questName} + +
+ + {actionSteps.length > 0 && ( +
+
+ {completedCount}/{actionSteps.length} étapes + {pct}% +
+
+
+
+
+ )} + + {questUrl && ( + + )} +
+ + {/* Content */} +
+ {loading && ( +
+ + Chargement… +
+ )} + + {error && ( +
+ {error} +
+ )} + + {!loading && !error && steps.length === 0 && ( +
+ Aucune étape trouvée sur la page. +
+ )} + + {/* Quest info header (first step) */} + {!loading && 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 ( +
+
+ toggleStep(step.index)} + style={{ marginTop: "2px", flexShrink: 0 }} + /> +
+
+ +
+ + {needsTruncate && ( + + )} + + {step.images.length > 0 && ( +
+ {step.images.map((src, j) => ( + + ))} +
+ )} +
+
+
+ ); + })} +
+
+ ); +} + +function QuestHeader({ step }: { step: QuestStep }) { + return ( +
+
+ 📍 + + Position + + + + +
+ + {step.images.length > 0 && ( +
+ {step.images.map((src, j) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx new file mode 100644 index 0000000..15baedb --- /dev/null +++ b/src/components/SettingsPanel.tsx @@ -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([]); + 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 ( +
+ {/* Header */} +
+ Paramètres + +
+ + {/* Scrollable content */} +
+ + {/* ── Profils ── */} +
+ Profils + +
+ {profiles.map(profile => { + const isActive = profile.id === activeProfileId; + return ( +
+ + {profiles.length > 1 && ( + + )} +
+ ); + })} +
+ +
+ { 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")} + /> + +
+ {profileError &&

{profileError}

} +
+ + {/* ── Synchronisation ── */} +
+ Synchronisation + +

+ Met à jour tous les guides depuis Google Sheets. +

+ + + + {syncing && syncProgress && ( +
+
+ {label} + {current}/{total} — {syncPct}% +
+
+
+
+
+ )} + + {syncDone && !syncing && ( +
+ {syncErrors.length === 0 + ? "✓ Synchronisation terminée." + : `⚠ ${syncErrors.length} erreur(s) :\n${syncErrors.join("\n")}`} +
+ )} +
+
+
+ ); +} + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index d63f980..95355f6 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,10 +1,23 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; 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() { - const { guides, openGuide, activeGuideGid, view } = useStore(); + const { guides, openGuide, activeGuideGid, view, sidebarCollapsed, setSidebarCollapsed } = useStore(); 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 => g.name.toLowerCase().includes(search.toLowerCase()) @@ -12,27 +25,40 @@ export default function Sidebar() { return (