diff --git a/.claude/agent-memory/dofus-scraper-architect/MEMORY.md b/.claude/agent-memory/dofus-scraper-architect/MEMORY.md new file mode 100644 index 0000000..98c1657 --- /dev/null +++ b/.claude/agent-memory/dofus-scraper-architect/MEMORY.md @@ -0,0 +1,4 @@ +# Memory Index + +- [HTML "À prévoir" section](html_a_prevoir_section.md) — Patterns, sélecteurs et mapping texte→combat_type pour la section À prévoir +- [Weebly rendering quirks](site_rendering_weebly.md) — Le site est un Weebly : cascades de span, libellés par texte, slugs avec entités HTML diff --git a/.claude/agent-memory/dofus-scraper-architect/html_a_prevoir_section.md b/.claude/agent-memory/dofus-scraper-architect/html_a_prevoir_section.md new file mode 100644 index 0000000..7e7a23d --- /dev/null +++ b/.claude/agent-memory/dofus-scraper-architect/html_a_prevoir_section.md @@ -0,0 +1,42 @@ +--- +name: HTML structure of "À prévoir" section +description: Pattern HTML, sélecteurs et règles de mapping pour la section "À prévoir" des pages de quête dofuspourlesnoobs.com +type: project +--- + +Le site est rendu via Weebly. L'en-tête de quête (Prérequis, Position de lancement, Récompenses, À prévoir) tient dans un seul `
`, en HTML plat, avec des cascades de `` parasites. + +**Identification de la section** : il n'existe pas de classe CSS dédiée. La seule clé fiable est le libellé textuel `À prévoir` (encodé `À prévoir`, parfois suivi d'un espace, parfois `:` à l'intérieur ou à l'extérieur du ``). Les couleurs `` du header (`#f00` Prérequis, `#3a96b8` Position de lancement, `#5fa233` Récompenses) **ne s'appliquent pas** à À prévoir — ne pas s'en servir comme discriminateur. + +**Structure typique** : +```html +À prévoir : +
    +
  • 2 x combats (réalisable en groupe).
  • +
  • 1 x Donjon Antre du Dragon Cochon.
  • +
+``` + +**Stratégie de parsing robuste** : sur le `inner_html()` du `
`, splitter via regex `(?i)]*>\s*À\s*prévoir\s*\s*:?` puis couper au prochain ``. Cette approche tolère la cascade de `` Weebly mieux que la navigation par siblings. + +**Cas "À savoir"** : sur quêtes simples (cryptologie, mise-à-l'épreuve), un `À savoir :` apparaît à la place — texte libre sans `
    `. Section distincte, ne pas la confondre. + +**Cas "absence"** : certaines quêtes (la-colere-des-dieux) n'ont ni À prévoir ni À savoir → retourner Vec::new(), c'est valide. + +**Mapping texte → combat_type observé** : +- `donjon` dans le texte → `"donjon"` (label = nom du donjon) +- `combat` + `seul` → `"solo"` +- `combat` + `groupe`/`réalisable en groupe` → `"groupe"` +- `combat à vagues` → `"combat_vagues"` +- `combat "tactique"` → `"combat_tactique"` +- `combats aléatoires` → `"combat_aleatoire"` +- `combats contre des monstres` → `"combat_zone"` +- `Aller à ` → `"deplacement"` +- `n x ` sans "combat"/"donjon" → `"item"` (matériaux à fournir) +- modifier observé : `évitable` → flag boolean + +**Count** : extraire via regex `^(\d+)\s*x\s*`. Si commence par `Des ` → quantité indéterminée (`"x?"`). + +**Why** : ces patterns ont été validés sur 10 pages réelles (espoirs-et-trageacutedies, dans-la-gueule-du-milimilou, voir-le-dark-vlad-et-mourir-ou-pas, mise-agrave-leacutepreuve, cryptologie, plongeon-et-dragon, le-dragon-noir, une-acircme-en-colegravere, a-la-recherche-de-crocoburio, l-oeuf-de-crocabulia) en avril 2026. + +**How to apply** : utiliser ce mapping dans `src-tauri/src/commands.rs` pour peupler `CombatIndicator`. Si la struct actuelle (combat_type + count) est conservée, le label du donjon/item est perdu — recommander d'ajouter `label: Option` et `evitable: bool` pour préserver l'info riche. diff --git a/.claude/agent-memory/dofus-scraper-architect/site_rendering_weebly.md b/.claude/agent-memory/dofus-scraper-architect/site_rendering_weebly.md new file mode 100644 index 0000000..b49d5fa --- /dev/null +++ b/.claude/agent-memory/dofus-scraper-architect/site_rendering_weebly.md @@ -0,0 +1,17 @@ +--- +name: dofuspourlesnoobs.com is a Weebly site +description: Caractéristiques du rendu Weebly du site et conséquences pour le scraping +type: project +--- + +dofuspourlesnoobs.com est hébergé sur Weebly. Conséquences pratiques pour le scraping : + +- Le contenu est servi en HTML statique côté serveur — pas de SPA, pas besoin de headless browser. +- L'éditeur WYSIWYG produit des **cascades énormes de `` vides** (parfois 100+ niveaux) autour du moindre fragment de texte. Tous les sélecteurs doivent ignorer cette pollution (utiliser `.text()` qui aplatit, ou splitter directement sur `inner_html()`). +- Les libellés de sections (Prérequis, Récompenses, À prévoir, etc.) sont identifiés **par texte**, pas par classe CSS. Aucune classe sémantique n'est ajoutée par Weebly. +- Les couleurs sont en `` inline (legacy), pas en CSS. Couleurs vues : `#f00`/`#f70000` Prérequis, `#3a96b8` Position de lancement, `#5fa233` Récompenses. À prévoir n'a pas de couleur propre. +- Les caractères accentués sont presque toujours encodés en entités HTML (`À`, `é`, etc.) dans la source — penser à `html.unescape` côté Rust ou utiliser `.text()` de scraper qui décode. +- Le `
    ` est le container Weebly de base pour un bloc de texte. Une page de quête type contient : 1 paragraph d'en-tête (méta-données) + N paragraphs d'étapes. +- Les slugs d'URL utilisent les entités HTML décodées : `é` → `eacute`, `à` → `agrave`, `ê` → `ecirc`, `ô` → `ocirc`, etc. Exemples : `quecirctes.html`, `mise-agrave-leacutepreuve.html`, `chemin-vers-meacuteriana.html`. + +**How to apply** : quand on conçoit un sélecteur, ne jamais s'appuyer sur la profondeur DOM ni sur des classes CSS sémantiques (elles n'existent pas). S'appuyer sur le texte des `` et la position relative dans `inner_html()` du paragraph parent. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0356497..1fc71e4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5701,6 +5701,7 @@ dependencies = [ "csv", "dirs-next", "gtk", + "regex", "reqwest 0.12.28", "rusqlite", "scraper", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2e69a45..29de2fc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,7 @@ chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4"] } dirs-next = "2" scraper = "0.20" +regex = "1" [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 a22fe6a..6b63232 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2,6 +2,7 @@ use tauri::{AppHandle, Emitter, Manager, State}; use tauri::window::Color; use serde::{Deserialize, Serialize}; use std::sync::Mutex; +use std::collections::HashMap; use rusqlite::Connection; use crate::{db, parser}; @@ -556,6 +557,112 @@ pub async fn open_image_viewer( Ok(()) } +/// Lit le cache SQLite pour une liste d'URLs et retourne les indicateurs déjà stockés. +/// Synchrone et instantané — utilisé au chargement de la vue pour afficher les données en cache. +#[tauri::command] +pub fn get_cached_previews( + state: State, + quest_urls: Vec, +) -> Result>, String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + Ok(db::get_cached_previews(&conn, &quest_urls)) +} + +/// Scrape toutes les quêtes d'un guide qui ne sont pas encore en cache, stocke les résultats +/// en DB et retourne l'ensemble `url → indicateurs` pour le guide demandé. +#[tauri::command] +pub async fn fetch_guide_previews( + state: State<'_, DbState>, + gid: String, +) -> Result>, String> { + // 1. Charge le guide depuis la DB (section critique minimale) + let guide = { + let conn = state.0.lock().map_err(|e| e.to_string())?; + db::get_guide(&conn, &gid) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Guide {} introuvable", gid))? + }; + + // 2. Collecte toutes les URLs des quêtes du guide + let all_urls: Vec = collect_quest_urls(&guide); + + // 3. Détermine quelles URLs ne sont pas encore en cache + let cached_urls = { + let conn = state.0.lock().map_err(|e| e.to_string())?; + db::get_cached_urls(&conn, &all_urls) + }; + + let urls_to_fetch: Vec = all_urls + .iter() + .filter(|u| !cached_urls.contains(*u)) + .cloned() + .collect(); + + // 4. Scrape les pages manquantes (bloquant → spawn_blocking) + for url in urls_to_fetch { + let url_clone = url.clone(); + let result = tokio::task::spawn_blocking(move || -> Result, String> { + 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_clone) + .send() + .map_err(|e| format!("Erreur réseau {} : {}", url_clone, e))? + .text() + .map_err(|e| e.to_string())?; + + Ok(parser::extract_a_prevoir(&html)) + }) + .await + .map_err(|e| e.to_string())?; + + // On persiste même si le résultat est vide (évite de re-scraper une page sans section) + match result { + Ok(indicators) => { + let conn = state.0.lock().map_err(|e| e.to_string())?; + db::upsert_preview(&conn, &url, &indicators).map_err(|e| e.to_string())?; + } + Err(e) => { + // Erreur réseau non fatale : on log et on continue + eprintln!("[fetch_guide_previews] Erreur pour {} : {}", url, e); + } + } + } + + // 5. Retourne l'ensemble du cache pour toutes les URLs du guide + let conn = state.0.lock().map_err(|e| e.to_string())?; + Ok(db::get_cached_previews(&conn, &all_urls)) +} + +/// Extrait toutes les URLs de quêtes depuis un `GuideData` (Quest + Group.quests). +fn collect_quest_urls(data: &parser::GuideData) -> Vec { + let mut urls = Vec::new(); + for section in &data.sections { + for item in §ion.items { + match item { + parser::SectionItem::Quest(q) => { + if let Some(url) = &q.url { + urls.push(url.clone()); + } + } + parser::SectionItem::Group(g) => { + for q in &g.quests { + if let Some(url) = &q.url { + urls.push(url.clone()); + } + } + } + parser::SectionItem::Instruction(_) => {} + } + } + } + urls +} + 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 065b05b..ff062e9 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -2,6 +2,9 @@ use rusqlite::{Connection, Result, params}; use serde::{Deserialize, Serialize}; use chrono::Utc; use uuid::Uuid; +use std::collections::HashMap; + +use crate::parser::CombatIndicator; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Profile { @@ -76,10 +79,125 @@ pub fn migrate(conn: &Connection) -> Result<()> { PRIMARY KEY (profile_id, resource_name), FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE ); + + CREATE TABLE IF NOT EXISTS quest_previews ( + quest_url TEXT PRIMARY KEY, + indicators_json TEXT NOT NULL, + cached_at TEXT NOT NULL DEFAULT (datetime('now')) + ); ")?; Ok(()) } +pub fn get_cached_previews(conn: &Connection, urls: &[String]) -> HashMap> { + if urls.is_empty() { + return HashMap::new(); + } + + // Construit les placeholders : (?1, ?2, …) + let placeholders: String = (1..=urls.len()) + .map(|i| format!("?{}", i)) + .collect::>() + .join(", "); + let sql = format!( + "SELECT quest_url, indicators_json FROM quest_previews WHERE quest_url IN ({})", + placeholders + ); + + let mut stmt = match conn.prepare(&sql) { + Ok(s) => s, + Err(_) => return HashMap::new(), + }; + + // rusqlite attend &dyn ToSql — on construit un vecteur de références + let params_vec: Vec<&dyn rusqlite::types::ToSql> = urls + .iter() + .map(|u| u as &dyn rusqlite::types::ToSql) + .collect(); + + let rows = match stmt.query_map(params_vec.as_slice(), |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) { + Ok(r) => r, + Err(_) => return HashMap::new(), + }; + + let mut map = HashMap::new(); + for row in rows.flatten() { + let (url, json) = row; + if let Ok(indicators) = serde_json::from_str::>(&json) { + map.insert(url, indicators); + } + } + map +} + +pub fn upsert_preview(conn: &Connection, url: &str, indicators: &[CombatIndicator]) -> Result<()> { + let json = serde_json::to_string(indicators).unwrap_or_else(|_| "[]".to_string()); + let now = Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO quest_previews (quest_url, indicators_json, cached_at) VALUES (?1, ?2, ?3) + ON CONFLICT(quest_url) DO UPDATE SET indicators_json=excluded.indicators_json, cached_at=excluded.cached_at", + params![url, json, now], + )?; + Ok(()) +} + +/// Retourne l'ensemble des URLs déjà présentes dans quest_previews parmi celles fournies. +pub fn get_cached_urls(conn: &Connection, urls: &[String]) -> std::collections::HashSet { + if urls.is_empty() { + return std::collections::HashSet::new(); + } + + let placeholders: String = (1..=urls.len()) + .map(|i| format!("?{}", i)) + .collect::>() + .join(", "); + let sql = format!( + "SELECT quest_url FROM quest_previews WHERE quest_url IN ({})", + placeholders + ); + + let mut stmt = match conn.prepare(&sql) { + Ok(s) => s, + Err(_) => return std::collections::HashSet::new(), + }; + + let params_vec: Vec<&dyn rusqlite::types::ToSql> = urls + .iter() + .map(|u| u as &dyn rusqlite::types::ToSql) + .collect(); + + let rows = match stmt.query_map(params_vec.as_slice(), |row| row.get::<_, String>(0)) { + Ok(r) => r, + Err(_) => return std::collections::HashSet::new(), + }; + + rows.flatten().collect() +} + +/// Charge un guide depuis la DB à partir de son gid. +pub fn get_guide(conn: &Connection, gid: &str) -> Result> { + let result = conn.query_row( + "SELECT data FROM guides WHERE gid = ?1", + params![gid], + |row| row.get::<_, String>(0), + ); + match result { + Ok(json) => { + let data = serde_json::from_str(&json) + .map_err(|e| rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Text, + Box::new(e), + ))?; + Ok(Some(data)) + } + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e), + } +} + pub fn get_profiles(conn: &Connection) -> Result> { let mut stmt = conn.prepare("SELECT id, name, created_at FROM profiles ORDER BY created_at ASC")?; let rows = stmt.query_map([], |row| { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 177ec6a..45deb5e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -60,6 +60,8 @@ pub fn run() { commands::get_resource_inventory, commands::set_resource_quantity, commands::open_image_viewer, + commands::get_cached_previews, + commands::fetch_guide_previews, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/parser.rs b/src-tauri/src/parser.rs index 6e57c86..fdbfcb9 100644 --- a/src-tauri/src/parser.rs +++ b/src-tauri/src/parser.rs @@ -50,6 +50,136 @@ pub struct QuestItem { pub struct CombatIndicator { pub combat_type: String, pub count: String, + #[serde(default)] + pub label: Option, + #[serde(default)] + pub evitable: bool, +} + +/// Extrait les indicateurs de combat depuis la section "À prévoir" d'une page de quête DPLN. +/// +/// La section est identifiée par un `` contenant "À prévoir" dans un `
    `. +/// Le fragment HTML entre ce marqueur et le prochain `` est re-parsé pour extraire les `
  • `. +pub fn extract_a_prevoir(html: &str) -> Vec { + use scraper::{Html, Selector}; + use regex::Regex; + + // Sélecteur sur tous les paragraphes potentiels du header de quête + let para_sel = Selector::parse("div.paragraph").unwrap(); + let document = Html::parse_document(html); + + // Regex pour détecter "À prévoir" dans le inner_html (gère les entités HTML) + let re_header = Regex::new( + r"(?i)]*>\s*(?:À|À)\s*pr(?:é|é)voir\s*:?\s*" + ).unwrap(); + + // Regex pour extraire le count en début de texte : "2 x ..." ou "Des ..." + let re_count = Regex::new(r"(?i)^(\d+)\s*[xX]\s*").unwrap(); + + for para in document.select(¶_sel) { + let inner = para.inner_html(); + + if !re_header.is_match(&inner) { + continue; + } + + // Coupe le fragment après le marqueur "À prévoir" jusqu'au prochain (champ suivant) + let after_marker = re_header.splitn(&inner, 2).nth(1).unwrap_or(""); + // Tronque au prochain pour ne pas déborder sur un autre champ + let fragment_html = match after_marker.find(" &after_marker[..pos], + None => after_marker, + }; + + let fragment = Html::parse_fragment(fragment_html); + let li_sel = Selector::parse("li").unwrap(); + + let mut indicators = Vec::new(); + + for li in fragment.select(&li_sel) { + let raw_text: String = li.text().collect::>().join(" "); + let text = raw_text.trim().to_string(); + if text.is_empty() { + continue; + } + + let lower = text.to_lowercase(); + let evitable = lower.contains("évitable") || lower.contains("evitable"); + + // Extraction du count + let count = if lower.starts_with("des ") { + "?".to_string() + } else if let Some(cap) = re_count.captures(&text) { + cap[1].to_string() + } else { + "1".to_string() + }; + + // Texte sans le préfixe "N x " + let text_without_count = re_count.replace(&text, "").to_string(); + let lower_no_count = text_without_count.to_lowercase(); + + // Mapping vers combat_type + let (combat_type, label) = if lower_no_count.contains("donjon") { + // Extrait le nom du donjon : tout sauf le mot "donjon" et ce qui suit + let label = extract_donjon_label(&text_without_count); + ("donjon".to_string(), Some(label)) + } else if lower_no_count.contains("combat") { + if lower_no_count.contains("tactique") { + ("combat_tactique".to_string(), None) + } else if lower_no_count.contains("vague") { + ("combat_vagues".to_string(), None) + } else if lower_no_count.contains("aléatoire") || lower_no_count.contains("aleatoire") { + ("combat_aleatoire".to_string(), None) + } else if lower_no_count.contains("monstre") { + ("combat_zone".to_string(), None) + } else if lower_no_count.contains("seul") { + ("solo".to_string(), None) + } else if lower_no_count.contains("groupe") || lower_no_count.contains("réalisable") || lower_no_count.contains("realisable") { + ("groupe".to_string(), None) + } else { + // Combat sans précision supplémentaire + ("groupe".to_string(), None) + } + } else if lower_no_count.contains("aller à") || lower_no_count.contains("aller a") { + ("deplacement".to_string(), None) + } else { + // Fallback : item nommé + let label = text_without_count + .trim() + .trim_end_matches('.') + .trim() + .to_string(); + ("item".to_string(), Some(label)) + }; + + indicators.push(CombatIndicator { + combat_type, + count, + label, + evitable, + }); + } + + // Premier paragraphe "À prévoir" trouvé — on retourne immédiatement + return indicators; + } + + Vec::new() +} + +/// Extrait le nom du donjon depuis un texte de type "Donjon Antre du Dragon Cochon." +/// Supprime le mot "Donjon" en début (case-insensitive), nettoie la ponctuation finale. +fn extract_donjon_label(text: &str) -> String { + let re = regex::Regex::new(r"(?i)^donjon\s*").unwrap(); + let without = re.replace(text, ""); + // Retire les suffixes parenthétiques courants : "(réalisable en groupe)." + let trimmed = if let Some(paren) = without.find('(') { + without[..paren].trim().to_string() + } else { + without.trim().trim_end_matches('.').trim().to_string() + }; + trimmed } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -284,6 +414,8 @@ fn parse_combat_indicators(row: &[String], legend: &[CombatType], checkbox_col: indicators.push(CombatIndicator { combat_type: ct.name.clone(), count: cell.to_string(), + label: None, + evitable: false, }); } indicators diff --git a/src/components/GuideView.tsx b/src/components/GuideView.tsx index acfbf98..7394d8b 100644 --- a/src/components/GuideView.tsx +++ b/src/components/GuideView.tsx @@ -16,6 +16,19 @@ function useWindowWidth() { function combatIcon(name: string): string { const l = name.toLowerCase(); + if (l === "solo") return "🗡️"; + if (l === "groupe") return "⚔️"; + if (l === "donjon") return "💀"; + if ( + l === "combat_vagues" || + l === "combat_tactique" || + l === "combat_aleatoire" || + l === "combat_zone" || + l === "combat" + ) return "🗡️"; + if (l === "deplacement") return "🗺️"; + if (l === "item") return "📦"; + // Fallback — conserve l'ancienne logique pour les données CSV existantes if (l.includes("solo") || l.includes("seul")) return "🗡️"; if (l.includes("group") || l.includes("groupe")) return "⚔️"; if (l.includes("donjon") || l.includes("boss")) return "💀"; @@ -324,6 +337,15 @@ function QuestRow({ quest, completed, onToggle, onSelect, indent }: { onSelect: (quest: { name: string; url: string | null }) => void; indent?: boolean; }) { + const questPreviews = useStore(s => s.questPreviews); + const previewsLoading = useStore(s => s.previewsLoading); + const [previewsOpen, setPreviewsOpen] = useState(false); + + const previews = quest.url ? questPreviews[quest.url] : undefined; + const showLoadingPlaceholder = previewsLoading && quest.url !== null && previews === undefined; + + const hasPreviewSection = showLoadingPlaceholder || (previews && previews.length > 0); + return (
    ))}
    + + {hasPreviewSection && ( + setPreviewsOpen(o => !o)} + style={{ + display: "inline-block", + marginTop: "2px", + fontSize: "10px", + color: "#4a5568", + cursor: "pointer", + userSelect: "none", + }} + onMouseEnter={e => { (e.currentTarget as HTMLElement).style.color = "#94a3b8"; }} + onMouseLeave={e => { (e.currentTarget as HTMLElement).style.color = "#4a5568"; }} + > + {previewsOpen + ? `▾ À prévoir` + : `▸ À prévoir${previews && previews.length > 0 ? ` (${previews.length})` : ""}` + } + + )} + + {previewsOpen && ( + <> + {showLoadingPlaceholder && ( +
    + )} + + {!showLoadingPlaceholder && previews && previews.length > 0 && ( +
    + {previews.map((ci, i) => ( + + {combatIcon(ci.combat_type)} + ×{ci.count}{ci.combat_type !== "item" ? ` ${ci.combat_type}` : ""} + {ci.evitable && ( + (évit.) + )} + {ci.label && ( + + {ci.label} + + )} + + ))} +
    + )} + + )} + {quest.note && (
    diff --git a/src/index.css b/src/index.css index 1f764ce..1c7f5b8 100644 --- a/src/index.css +++ b/src/index.css @@ -110,6 +110,11 @@ input[type="checkbox"] { to { transform: rotate(360deg); } } +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + .animate-fade-in { animation: fadeIn 0.2s ease-out; } diff --git a/src/store.ts b/src/store.ts index 2a38db4..c1d8a22 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import { invoke } from "@tauri-apps/api/core"; -import { Profile, GuideListItem, GuideData, SyncResult } from "./types"; +import { Profile, GuideListItem, GuideData, SyncResult, CombatIndicator, Section } from "./types"; interface AppState { profiles: Profile[]; @@ -15,6 +15,8 @@ interface AppState { sidebarCollapsed: boolean; resourcesPanelCollapsed: boolean; resourceInventory: Record; + questPreviews: Record; + previewsLoading: boolean; setResourcesPanelCollapsed: (v: boolean) => void; loadResourceInventory: () => Promise; @@ -28,6 +30,7 @@ interface AppState { openGuide: (gid: string) => Promise; setSidebarCollapsed: (collapsed: boolean) => void; closeGuide: () => void; + loadQuestPreviews: (gid: string) => Promise; toggleQuest: (questName: string) => Promise; @@ -48,6 +51,8 @@ export const useStore = create((set, get) => ({ sidebarCollapsed: false, resourcesPanelCollapsed: false, resourceInventory: {}, + questPreviews: {}, + previewsLoading: false, setResourcesPanelCollapsed: (v) => set({ resourcesPanelCollapsed: v }), @@ -112,13 +117,32 @@ export const useStore = create((set, get) => ({ const data = await invoke("get_guide", { gid }); await invoke("set_setting", { key: "active_guide", value: gid }); set({ activeGuideGid: gid, activeGuideData: data, view: "guide" }); + await get().loadQuestPreviews(gid); }, setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }), closeGuide: () => { invoke("set_setting", { key: "active_guide", value: "" }); - set({ activeGuideGid: null, activeGuideData: null, view: "home" }); + set({ activeGuideGid: null, activeGuideData: null, view: "home", questPreviews: {}, previewsLoading: false }); + }, + + loadQuestPreviews: async (gid) => { + const { activeGuideData } = get(); + if (!activeGuideData) return; + + const urls = collectQuestUrls(activeGuideData.sections); + if (urls.length === 0) return; + + // Hydratation immédiate depuis le cache DB + const cached = await invoke>("get_cached_previews", { questUrls: urls }); + set({ questPreviews: cached }); + + // Fetch réseau en tâche de fond (fire & forget) + set({ previewsLoading: true }); + invoke>("fetch_guide_previews", { gid }) + .then(result => set({ questPreviews: result, previewsLoading: false })) + .catch(() => set({ previewsLoading: false })); }, toggleQuest: async (questName) => { @@ -164,3 +188,18 @@ export const useStore = create((set, get) => ({ await invoke("sync_single_guide", { gid, name }); }, })); + +function collectQuestUrls(sections: Section[]): string[] { + const urls: string[] = []; + for (const section of sections) { + for (const item of section.items) { + if (item.type === "Quest" && item.url) urls.push(item.url); + else if (item.type === "Group") { + for (const q of item.quests) { + if (q.url) urls.push(q.url); + } + } + } + } + return urls; +} diff --git a/src/types.ts b/src/types.ts index 5f50a07..cfd542c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,6 +25,8 @@ export interface Resource { export interface CombatIndicator { combat_type: string; count: string; + label?: string; + evitable?: boolean; } export interface QuestItem {