Compare commits
8 Commits
a780dd7051
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cd1c8becb | |||
| 4702d9387e | |||
| 5a747222fc | |||
| 4f960ff41f | |||
| b0e6d09301 | |||
| e3095ecf10 | |||
| 9ff8088ce5 | |||
| 3068b3e352 |
@ -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.
|
||||
|
||||
17
.github/workflows/build.yml
vendored
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||

|
||||
|
||||
### Page principale
|
||||
|
||||

|
||||
|
||||
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 101 KiB |
BIN
screenshots/overall.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 363 KiB |
1
src-tauri/Cargo.lock
generated
@ -5700,6 +5700,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"csv",
|
||||
"dirs-next",
|
||||
"ego-tree",
|
||||
"gtk",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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 …"
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -1 +1,2 @@
|
||||
import { vi } from 'vitest';
|
||||
export const invoke = vi.fn().mockResolvedValue(null);
|
||||
|
||||
111
src/components/DofusIconWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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={{
|
||||
|
||||
@ -31,7 +31,7 @@ export default function ResizeHandles() {
|
||||
{handles.map(({ edge, style }) => (
|
||||
<div
|
||||
key={edge}
|
||||
onMouseDown={e => { if (e.button === 0) win.startResizeDragging(edge); }}
|
||||
onMouseDown={e => { if (e.button === 0) { e.preventDefault(); win.startResizeDragging(edge); } }}
|
||||
style={{ position: "fixed", zIndex: 9999, ...style }}
|
||||
/>
|
||||
))}
|
||||
|
||||
@ -23,6 +23,7 @@ export default function TitleBar({ onOpenSettings }: Props) {
|
||||
|
||||
function handleDragMouseDown(e: React.MouseEvent) {
|
||||
if (e.button === 0) {
|
||||
e.preventDefault();
|
||||
getCurrentWindow().startDragging();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@700&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||