6 Commits

Author SHA1 Message Date
5a747222fc feat: dofus icone added to guide view and font changed
Some checks failed
Release / create-release (push) Has been cancelled
Release / build-linux (push) Has been cancelled
Release / build-windows (push) Has been cancelled
Release / build-macos (push) Has been cancelled
2026-04-26 12:51:16 +02:00
4f960ff41f feat: add dofus Icones on main page 2026-04-26 10:58:52 +02:00
b0e6d09301 fix: correct artifact names in release job
Some checks failed
Release / create-release (push) Has been cancelled
Release / build-linux (push) Has been cancelled
Release / build-windows (push) Has been cancelled
Release / build-macos (push) Has been cancelled
2026-04-25 23:02:38 +02:00
e3095ecf10 fix: add missing vitest import
Some checks failed
Release / create-release (push) Has been cancelled
Release / build-linux (push) Has been cancelled
Release / build-windows (push) Has been cancelled
Release / build-macos (push) Has been cancelled
2026-04-25 18:02:08 +02:00
9ff8088ce5 docs: update README 2026-04-25 17:58:12 +02:00
3068b3e352 feat: highlight link to other quest or copy item name in quest details 2026-04-25 16:06:06 +02:00
15 changed files with 412 additions and 90 deletions

View File

@ -65,6 +65,19 @@ You are fluent in:
4. **Régression** — Regression analysis (if applicable)
5. **Code corrigé** — Provide corrected code snippets for all 🔴 and 🟠 issues
## Unit Testing — Mandatory Protocol
After **every** feature implementation or code update, you must:
1. **Run the full test suite** to detect regressions:
```bash
cd /home/anthony/Documents/Projects/TougliGui && npm run test
```
2. **Write or update unit tests** for anything you added or changed. Tests live in `src/__tests__/`. Use Vitest + Testing Library (already configured in the project).
3. **Report the test results** at the end of your response: number of tests passing, any failures, and which tests you added or modified.
Never consider a task complete without running the tests. If a test fails, fix the issue before reporting done.
## Behavioral Guidelines
- **Default language**: Respond in the same language as the user (French or English).
- **Be decisive**: When multiple valid approaches exist, recommend one and explain briefly why.

View File

@ -81,4 +81,19 @@ jobs:
- name: Télécharger les artefacts Windows
uses: actions/download-artifact@v4
with:
name: w
name: windows-build
path: artifacts/windows
- name: Télécharger les artefacts Linux
uses: actions/download-artifact@v4
with:
name: linux-build
path: artifacts/linux
- name: Créer la release GitHub
uses: softprops/action-gh-release@v2
with:
files: |
artifacts/windows/**
artifacts/linux/**
generate_release_notes: true

View File

@ -22,6 +22,8 @@
TougliGui est une application desktop permettant de suivre sa progression dans les guides Dofus [Tougli](https://docs.google.com/spreadsheets/d/1uL7svJ0E0MjhqHVLU7O4Q8v7iGwPd4bsI9qV-Pdhdds/edit?gid=0#gid=0 "lien du guide Tougli") (Dofus Argenté, Dofus Émeraude, Dofus Cauchemar, etc.). Les données sont synchronisées depuis Google Sheets et stockées localement dans SQLite. Chaque profil conserve sa propre progression indépendamment des autres.
![Overall](screenshots/overall.png)
### Page principale
![Page principale](screenshots/accueil.png)

BIN
screenshots/overall.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

1
src-tauri/Cargo.lock generated
View File

@ -5700,6 +5700,7 @@ dependencies = [
"chrono",
"csv",
"dirs-next",
"ego-tree",
"gtk",
"regex",
"reqwest 0.12.28",

View File

@ -27,6 +27,7 @@ chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
dirs-next = "2"
scraper = "0.20"
ego-tree = "0.6"
regex = "1"
[target.'cfg(target_os = "linux")'.dependencies]

View File

@ -208,12 +208,20 @@ pub fn set_always_on_top(app: AppHandle, value: bool) -> Result<(), String> {
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "type")]
pub enum RichSegment {
Text { text: String },
QuestLink { text: String, href: String },
}
#[derive(Debug, Serialize, Deserialize)]
pub struct QuestStep {
pub index: usize,
pub text: String,
pub images: Vec<String>,
pub launch_position: Option<String>,
pub rich_text: Vec<RichSegment>,
}
#[tauri::command]
@ -272,13 +280,14 @@ fn parse_quest_steps(html: &str) -> Result<Vec<QuestStep>, String> {
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 });
steps.push(QuestStep { index: steps.len(), text: String::new(), images, launch_position: pos, rich_text: vec![] });
}
continue; // always skip as a regular step
}
}
steps.push(QuestStep { index: steps.len(), text, images: vec![], launch_position: None });
let rich_text = element_to_rich_text(&child);
steps.push(QuestStep { index: steps.len(), text, images: vec![], launch_position: None, rich_text });
continue;
}
@ -304,16 +313,17 @@ fn parse_quest_steps(html: &str) -> Result<Vec<QuestStep>, String> {
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 });
steps.push(QuestStep { index: steps.len(), text: String::new(), images: imgs, launch_position: pos, rich_text: vec![] });
}
first_para = false;
continue;
}
}
let rich_text = element_to_rich_text(para);
let imgs = if first_para { images.clone() } else { vec![] };
first_para = false;
steps.push(QuestStep { index: steps.len(), text, images: imgs, launch_position: None });
steps.push(QuestStep { index: steps.len(), text, images: imgs, launch_position: None, rich_text });
}
}
}
@ -410,6 +420,105 @@ fn collect_images_from(
images
}
fn is_quest_link(href: &str) -> bool {
href.starts_with('/')
&& href.ends_with(".html")
&& !href.contains("/uploads/")
&& !href.contains('#')
}
fn collect_rich_impl(
node: ego_tree::NodeRef<scraper::Node>,
segments: &mut Vec<RichSegment>,
buf: &mut String,
) {
for child in node.children() {
match child.value() {
scraper::Node::Text(t) => {
let s = t.text.trim();
if !s.is_empty() {
if !buf.is_empty() && !buf.ends_with(|c: char| c.is_whitespace()) {
buf.push(' ');
}
buf.push_str(s);
}
}
scraper::Node::Element(e) => {
let tag = e.name();
if matches!(tag, "script" | "style" | "noscript") {
// skip subtree
} else if tag == "a" {
if let Some(href) = e.attr("href") {
if is_quest_link(href) {
let t = std::mem::take(buf);
let t = t.trim().to_string();
if !t.is_empty() {
segments.push(RichSegment::Text { text: t });
}
let link_text: String = child
.descendants()
.filter_map(|n| {
if let scraper::Node::Text(txt) = n.value() {
let s = txt.text.trim();
if s.is_empty() { None } else { Some(s.to_string()) }
} else {
None
}
})
.collect::<Vec<_>>()
.join(" ");
if !link_text.is_empty() {
segments.push(RichSegment::QuestLink {
text: link_text,
href: href.to_string(),
});
}
// Don't recurse into the <a>
} else {
collect_rich_impl(child, segments, buf);
}
} else {
collect_rich_impl(child, segments, buf);
}
} else {
if matches!(tag, "p" | "br" | "li" | "div" | "tr" | "h1" | "h2" | "h3" | "h4") {
if !buf.is_empty() && !buf.ends_with('\n') {
buf.push('\n');
}
}
collect_rich_impl(child, segments, buf);
}
}
_ => {}
}
}
}
fn element_to_rich_text(el: &scraper::ElementRef) -> Vec<RichSegment> {
let mut segments: Vec<RichSegment> = Vec::new();
let mut buf = String::new();
collect_rich_impl(**el, &mut segments, &mut buf);
// Flush remaining text
let mut result = String::new();
let mut prev_empty = false;
for line in buf.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;
}
}
let t = result.trim().to_string();
if !t.is_empty() {
segments.push(RichSegment::Text { text: t });
}
segments
}
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 …"

View File

@ -398,43 +398,34 @@ fn is_resource_row(row: &[String]) -> Option<(u32, String)> {
None
}
fn parse_combat_indicators(row: &[String], legend: &[CombatType], checkbox_col: usize) -> Vec<CombatIndicator> {
let mut indicators = Vec::new();
for ct in legend {
// Skip the checkbox column and the quest name column — they are not combat indicators
if ct.column <= checkbox_col + 1 { continue; }
let cell = get_cell(row, ct.column);
// Skip empty cells and boolean-looking values
if cell.is_empty()
|| cell.eq_ignore_ascii_case("false")
|| cell.eq_ignore_ascii_case("true")
{
continue;
}
indicators.push(CombatIndicator {
combat_type: ct.name.clone(),
count: cell.to_string(),
label: None,
evitable: false,
});
}
indicators
fn parse_combat_indicators(_row: &[String], _legend: &[CombatType], _checkbox_col: usize) -> Vec<CombatIndicator> {
Vec::new()
}
fn extract_note(row: &[String], name_col: usize, legend: &[CombatType]) -> Option<String> {
// Note is typically in the last significant column after the combat indicators
// Search for non-empty cell after col name_col+1, skipping combat indicator cols
use regex::Regex;
// Matches any combat-indicator cell: optional arrow prefix, optional "x", digits, optional suffix
// Covers: "→x3", "→ x5 (Aléatoire)", "->x1 (évitable)", "3", "x2", etc.
static RE_COMBAT_CELL: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let re = RE_COMBAT_CELL.get_or_init(|| {
Regex::new(r"(?i)^(?:→+|-+>)\s*[x×]?\s*\d|^[x×]?\s*\d+$").unwrap()
});
let combat_cols: std::collections::HashSet<usize> = legend.iter().map(|c| c.column).collect();
let max_search = row.len().min(36);
for col in (name_col + 1)..max_search {
let cell = get_cell(row, col);
if !cell.is_empty() && !combat_cols.contains(&col) {
// Likely a note
if !cell.eq_ignore_ascii_case("false") && !cell.eq_ignore_ascii_case("true") {
let cell = get_cell(row, col).trim();
if cell.is_empty() || combat_cols.contains(&col) {
continue;
}
if cell.eq_ignore_ascii_case("false") || cell.eq_ignore_ascii_case("true") {
continue;
}
if re.is_match(cell) {
continue;
}
return Some(cell.to_string());
}
}
}
None
}

View File

@ -1 +1,2 @@
import { vi } from 'vitest';
export const invoke = vi.fn().mockResolvedValue(null);

View File

@ -0,0 +1,111 @@
import { useEffect } from "react";
const DOFUS_SHIMMER_STYLE_ID = "dofus-icon-shimmer";
function injectShimmerStyle() {
if (document.getElementById(DOFUS_SHIMMER_STYLE_ID)) return;
const style = document.createElement("style");
style.id = DOFUS_SHIMMER_STYLE_ID;
style.textContent = `
@keyframes dofus-shimmer {
from { filter: brightness(1); }
to { filter: brightness(1.35); }
}
`;
document.head.appendChild(style);
}
// Mapping statique gid (Google Sheets ID) -> URL icône via api.dofusdb.fr
// Pattern URL : https://api.dofusdb.fr/img/items/{iconId}.png
const DOFUS_ICON_BASE = "https://api.dofusdb.fr/img/items";
export const GID_TO_ICON: Record<string, string> = {
"474870200": `${DOFUS_ICON_BASE}/23009.png`, // Dofawa
"743703882": `${DOFUS_ICON_BASE}/23025.png`, // Dofus Argenté
"103963898": `${DOFUS_ICON_BASE}/23006.png`, // Dofus Cawotte
"1075294690": `${DOFUS_ICON_BASE}/23022.png`, // Dokoko
"1567240526": `${DOFUS_ICON_BASE}/23020.png`, // Dofus des Veilleurs
"1011508069": `${DOFUS_ICON_BASE}/23002.png`, // Dofus Emeraude
"2045137654": `${DOFUS_ICON_BASE}/23001.png`, // Dofus Pourpre
"1967508888": `${DOFUS_ICON_BASE}/23032.png`, // Domakuro
"1382359191": `${DOFUS_ICON_BASE}/23033.png`, // Dorigami
"1413546794": `${DOFUS_ICON_BASE}/23003.png`, // Dofus Turquoise
"1641656252": `${DOFUS_ICON_BASE}/23005.png`, // Dofus des Glaces
"953522228": `${DOFUS_ICON_BASE}/23023.png`, // Dofus Abyssal
"818597042": `${DOFUS_ICON_BASE}/23039.png`, // Dofoozbz
"1021129660": `${DOFUS_ICON_BASE}/23016.png`, // Dofus Nébuleux
"595670723": `${DOFUS_ICON_BASE}/23004.png`, // Dofus Vulbis
"544349966": `${DOFUS_ICON_BASE}/23008.png`, // Dofus Tacheté
"1150302145": `${DOFUS_ICON_BASE}/23024.png`, // Dofus Forgelave
"882278553": `${DOFUS_ICON_BASE}/23007.png`, // Dofus Ebène
"200570588": `${DOFUS_ICON_BASE}/23011.png`, // Dofus Ivoire
"1209269839": `${DOFUS_ICON_BASE}/23012.png`, // Dofus Ocre
"462784268": `${DOFUS_ICON_BASE}/23027.png`, // Dofus Argenté Scintillant
"1543573905": `${DOFUS_ICON_BASE}/23034.png`, // Dofus Cauchemar
"1007491889": `${DOFUS_ICON_BASE}/23035.png`, // Dom de Pin
"1047555165": `${DOFUS_ICON_BASE}/23036.png`, // Dofus Sylvestre
"2105601828": `${DOFUS_ICON_BASE}/23029.png`, // Dofus Cacao
"474510463": `${DOFUS_ICON_BASE}/23017.png`, // Dokille
"62476099": `${DOFUS_ICON_BASE}/23018.png`, // Dolmanax
"1873654554": `${DOFUS_ICON_BASE}/23019.png`, // Dotruche
"360188709": `${DOFUS_ICON_BASE}/23010.png`, // Dofus Kaliptus
};
export function DofusIcon({ gid, pct, size = 44, left = 0 }: { gid: string; pct: number; size?: number; left?: number }) {
useEffect(injectShimmerStyle, []);
const iconUrl = GID_TO_ICON[gid] ?? null;
if (!iconUrl) return null;
// L'icône colorée est clippée du bas vers le haut selon pct.
// clipPath: inset(top right bottom left) — on réduit depuis le haut.
const filledClip = `inset(${100 - pct}% 0 0 0)`;
return (
<div style={{
position: "absolute",
top: 0,
left,
width: size,
height: size,
filter: "drop-shadow(0 2px 6px rgba(0,0,0,0.6))",
zIndex: 2,
flexShrink: 0,
}}>
{/* Calque grisé (base) */}
<img
src={iconUrl}
alt=""
aria-hidden="true"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "contain",
filter: "grayscale(1) brightness(0.45)",
userSelect: "none",
pointerEvents: "none",
}}
/>
{/* Calque coloré, progressivement révélé du bas vers le haut */}
{pct > 0 && (
<img
src={iconUrl}
alt=""
aria-hidden="true"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "contain",
clipPath: filledClip,
userSelect: "none",
pointerEvents: "none",
animation: "dofus-shimmer 2s ease-in-out infinite alternate",
}}
/>
)}
</div>
);
}

View File

@ -3,6 +3,7 @@ import { useStore } from "../store";
import { SectionItem, QuestItem, CombatType } from "../types";
import QuestDetailPanel from "./QuestDetailPanel";
import { TextWithCoords } from "./TextWithCoords";
import { DofusIcon, GID_TO_ICON } from "./DofusIconWidget";
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
@ -53,12 +54,13 @@ export default function GuideView() {
questUrl={selectedQuest.url}
profileId={activeProfileId}
onClose={() => setSelectedQuest(null)}
onSelectQuest={setSelectedQuest}
/>
</div>
);
}
const { name, effect, recommended_level, resources, sections, combat_legend } = activeGuideData;
const { name, effect, recommended_level, resources, sections, combat_legend, gid } = activeGuideData;
const allQuests = collectAllQuests(sections);
const completedCount = allQuests.filter(q => completedQuests.has(q)).length;
@ -72,29 +74,44 @@ export default function GuideView() {
{/* Header */}
<div style={{ marginBottom: "20px" }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: "8px", flexWrap: "wrap" }}>
{/* Zone gauche : icône + nom + niveau recommandé */}
<div style={{ display: "flex", alignItems: "flex-end", gap: "8px", minWidth: 0 }}>
{GID_TO_ICON[gid] && (
<div style={{ position: "relative", flexShrink: 0, width: 52, height: 52 }}>
<DofusIcon gid={gid} pct={pct} size={52} />
</div>
)}
<div style={{ minWidth: 0 }}>
<h1 style={{ fontSize: "18px", fontWeight: 700, color: "#f0c040", marginBottom: "2px", wordBreak: "break-word" }}>{name}</h1>
<h1 style={{
fontSize: "18px", fontWeight: 700, color: "#f0c040",
marginBottom: "2px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
}}>
{name}
</h1>
{recommended_level && (
<div style={{ fontSize: "12px", color: "#94a3b8" }}>
Niv. recommandé : <span style={{ color: "#e2e8f0", fontWeight: 600 }}>{recommended_level}</span>
</div>
)}
</div>
<div style={{ textAlign: "right", flexShrink: 0 }}>
<div style={{ fontSize: "13px", fontWeight: 700, color: isDone ? "#4ade80" : "#f0c040" }}>
{completedCount} / {allQuests.length}
</div>
<div style={{ fontSize: "11px", color: "#94a3b8" }}>{pct}%</div>
</div>
</div>
<div style={{ height: "4px", background: "#2d3748", borderRadius: "2px", overflow: "hidden", marginTop: "10px" }}>
<div style={{ marginTop: "10px" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: "4px" }}>
<span style={{ fontSize: "12px", fontWeight: 700, color: isDone ? "#4ade80" : "#f0c040" }}>
{completedCount} / {allQuests.length} quêtes
</span>
<span style={{ fontSize: "11px", color: "#94a3b8" }}>{pct}%</span>
</div>
<div style={{ height: "4px", background: "#2d3748", borderRadius: "2px", overflow: "hidden" }}>
<div style={{
height: "100%", width: `${pct}%`,
background: isDone ? "#4ade80" : "linear-gradient(90deg, #4a9eff, #f0c040)",
borderRadius: "2px", transition: "width 0.3s ease",
}} />
</div>
</div>
{effect && (
<div style={{
@ -380,11 +397,6 @@ function QuestRow({ quest, completed, onToggle, onSelect, indent }: {
>
{quest.name}
</span>
{quest.combat_indicators.map((ci, i) => (
<span key={i} style={{ fontSize: "11px", color: "#94a3b8", whiteSpace: "nowrap", flexShrink: 0 }}>
{combatIcon(ci.combat_type)} x{ci.count}
</span>
))}
</div>
{hasPreviewSection && (

View File

@ -1,4 +1,5 @@
import { useStore } from "../store";
import { DofusIcon, GID_TO_ICON } from "./DofusIconWidget";
export default function HomeView({ needsSync, onSync }: { needsSync?: boolean; onSync?: () => void }) {
const { guides, openGuide, profiles, activeProfileId, syncing } = useStore();
@ -14,7 +15,7 @@ export default function HomeView({ needsSync, onSync }: { needsSync?: boolean; o
<div className="with-scrollbar" style={{ flex: 1, overflowY: "auto", padding: "20px 24px", minHeight: 0 }}>
{/* Header */}
<div style={{ marginBottom: "20px" }}>
<h1 style={{ fontSize: "20px", fontWeight: 700, color: "#f0c040", marginBottom: "2px" }}>
<h1 style={{ fontSize: "20px", fontWeight: 700, color: "#f0c040", marginBottom: "2px", fontFamily: "'Cinzel Decorative', serif" }}>
Tougli Guide Dofus
</h1>
{activeProfile && (
@ -118,7 +119,7 @@ function Section({ title, guides, onOpen }: {
}}>
{title}
</h2>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(175px, 1fr))", gap: "8px" }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(210px, 1fr))", gap: "8px", paddingTop: "20px" }}>
{guides.map(g => <GuideCard key={g.gid} guide={g} onOpen={onOpen} />)}
</div>
</div>
@ -132,44 +133,62 @@ function GuideCard({ guide, onOpen }: {
const pct = guide.total_quests > 0 ? Math.round((guide.completed_quests / guide.total_quests) * 100) : 0;
const isDone = pct === 100 && guide.total_quests > 0;
const inProgress = guide.completed_quests > 0 && !isDone;
const hasIcon = GID_TO_ICON[guide.gid] != null;
const accentColor = isDone ? "#4ade80" : inProgress ? "#f0c040" : "#4a9eff";
const borderColor = isDone ? "rgba(74,222,128,0.25)" : "#2d3748";
return (
// Wrapper pour permettre à l'icône de déborder vers le haut
<div style={{ position: "relative", paddingTop: hasIcon ? "18px" : "0" }}>
<DofusIcon gid={guide.gid} pct={pct} size={44} left={8} />
<button
onClick={() => onOpen(guide.gid)}
style={{
background: "#161b22", border: `1px solid ${isDone ? "rgba(74,222,128,0.25)" : "#2d3748"}`,
borderRadius: "8px", padding: "12px 14px", cursor: "pointer",
textAlign: "left", transition: "all 0.15s", position: "relative", overflow: "hidden",
width: "100%",
background: "#161b22",
border: `1px solid ${borderColor}`,
borderRadius: "8px",
padding: "10px 12px",
cursor: "pointer",
textAlign: "left",
transition: "border-color 0.15s, background 0.15s",
position: "relative",
overflow: "hidden",
}}
onMouseEnter={e => {
(e.currentTarget as HTMLElement).style.borderColor = accentColor;
(e.currentTarget as HTMLElement).style.background = "#1a2233";
}}
onMouseLeave={e => {
(e.currentTarget as HTMLElement).style.borderColor = isDone ? "rgba(74,222,128,0.25)" : "#2d3748";
(e.currentTarget as HTMLElement).style.borderColor = borderColor;
(e.currentTarget as HTMLElement).style.background = "#161b22";
}}
>
{/* Indicateur latéral */}
{/* Nom + checkmark */}
<div style={{
position: "absolute", left: 0, top: 0, bottom: 0, width: "3px",
background: accentColor, opacity: isDone ? 1 : inProgress ? 0.8 : 0.3,
borderRadius: "8px 0 0 8px",
}} />
<div style={{ paddingLeft: "4px" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "8px" }}>
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "8px",
paddingLeft: hasIcon ? "46px" : "0",
minWidth: 0,
}}>
<span style={{
fontSize: "12px", fontWeight: 600, lineHeight: 1.3,
color: isDone ? "#4ade80" : "#e2e8f0",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
minWidth: 0,
}}>
{guide.name}
</span>
{isDone && <span style={{ fontSize: "12px", flexShrink: 0 }}></span>}
</div>
{/* Barre de progression */}
<div style={{ height: "3px", background: "#2d3748", borderRadius: "2px", overflow: "hidden", marginBottom: "6px" }}>
<div style={{
height: "100%", width: `${pct}%`,
@ -178,6 +197,7 @@ function GuideCard({ guide, onOpen }: {
}} />
</div>
{/* Compteur */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "10px", color: "#4a5568" }}>
{guide.completed_quests}/{guide.total_quests} quêtes
@ -186,7 +206,7 @@ function GuideCard({ guide, onOpen }: {
{pct}%
</span>
</div>
</div>
</button>
</div>
);
}

View File

@ -1,17 +1,20 @@
import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import { QuestStep } from "../types";
import { QuestStep, RichSegment } from "../types";
import { TextWithCoords } from "./TextWithCoords";
const DPLN_BASE = "https://www.dofuspourlesnoobs.com";
interface Props {
questName: string;
questUrl: string | null;
profileId: string;
onClose: () => void;
onSelectQuest?: (quest: { name: string; url: string | null }) => void;
}
export default function QuestDetailPanel({ questName, questUrl, profileId, onClose }: Props) {
export default function QuestDetailPanel({ questName, questUrl, profileId, onClose, onSelectQuest }: Props) {
const [steps, setSteps] = useState<QuestStep[]>([]);
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
const [expandedSteps, setExpandedSteps] = useState<Set<number>>(new Set());
@ -187,9 +190,6 @@ export default function QuestDetailPanel({ questName, questUrl, profileId, onClo
const lines = step.text.split('\n').filter(l => l.trim().length > 0);
const needsTruncate = lines.length > 4;
const displayText = needsTruncate && !expanded
? lines.slice(0, 4).join('\n')
: step.text;
return (
<div key={step.index} onClick={() => toggleStep(step.index)} style={{
@ -213,7 +213,10 @@ export default function QuestDetailPanel({ questName, questUrl, profileId, onClo
fontSize: "12px", color: "#94a3b8",
lineHeight: 1.6, whiteSpace: "pre-wrap", wordBreak: "break-word",
}}>
<TextWithCoords text={displayText} />
{step.rich_text.length > 0 && (expanded || !needsTruncate)
? <RichText segments={step.rich_text} onSelectQuest={onSelectQuest} />
: <TextWithCoords text={needsTruncate && !expanded ? lines.slice(0, 4).join('\n') : step.text} />
}
</div>
{needsTruncate && (
@ -255,6 +258,42 @@ export default function QuestDetailPanel({ questName, questUrl, profileId, onClo
);
}
function RichText({
segments,
onSelectQuest,
}: {
segments: RichSegment[];
onSelectQuest?: (quest: { name: string; url: string | null }) => void;
}) {
return (
<>
{segments.map((seg, i) => {
if (seg.type === "QuestLink") {
return (
<span
key={i}
onClick={e => {
e.stopPropagation();
onSelectQuest?.({ name: seg.text, url: DPLN_BASE + seg.href });
}}
style={{
color: "#4a9eff",
textDecoration: "underline",
cursor: "pointer",
}}
onMouseEnter={e => (e.currentTarget.style.color = "#93c5fd")}
onMouseLeave={e => (e.currentTarget.style.color = "#4a9eff")}
>
{seg.text}
</span>
);
}
return <TextWithCoords key={i} text={seg.text} />;
})}
</>
);
}
function QuestHeader({ step }: { step: QuestStep }) {
return (
<div style={{

View File

@ -1,3 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@700&display=swap');
@import "tailwindcss";
@theme {

View File

@ -10,6 +10,7 @@ export interface GuideListItem {
last_synced_at: string | null;
total_quests: number;
completed_quests: number;
image_url?: string;
}
export interface CombatType {
@ -71,9 +72,14 @@ export interface SyncResult {
errors: string[];
}
export type RichSegment =
| { type: "Text"; text: string }
| { type: "QuestLink"; text: string; href: string };
export interface QuestStep {
index: number;
text: string;
images: string[];
launch_position: string | null;
rich_text: RichSegment[];
}