15 Commits

37 changed files with 2241 additions and 1056 deletions

84
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,84 @@
name: Build & Release
on:
push:
branches:
- '**' # toutes les branches
tags:
- 'v*'
pull_request:
workflow_dispatch:
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: 'windows-latest'
name: 'windows'
- platform: 'ubuntu-22.04'
name: 'linux'
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: Installer Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
- name: Installer Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust
uses: swatinem/rust-cache@v2
with:
workspaces: './src-tauri -> target'
- name: Installer les dépendances système Linux
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Installer les dépendances npm
run: npm ci
- name: Build Tauri
run: npm run tauri build
- name: Upload Windows artifacts
if: matrix.platform == 'windows-latest' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
uses: actions/upload-artifact@v4
with:
name: windows-build
path: |
src-tauri/target/release/bundle/msi/*.msi
src-tauri/target/release/bundle/nsis/*.exe
- name: Upload Linux artifacts
if: matrix.platform == 'ubuntu-22.04' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
uses: actions/upload-artifact@v4
with:
name: linux-build
path: |
src-tauri/target/release/bundle/deb/*.deb
src-tauri/target/release/bundle/rpm/*.rpm
src-tauri/target/release/bundle/appimage/*.AppImage
release:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Télécharger les artefacts Windows
uses: actions/download-artifact@v4
with:
name: w

11
.gitignore vendored
View File

@ -7,7 +7,16 @@ dist-ssr/
# Rust / Tauri
src-tauri/target/
src-tauri/gen/schemas/
src-tauri/gen/
# Local dev scripts (machine-specific paths)
run.sh
# Template assets (not used)
public/tauri.svg
public/vite.svg
src/assets/react.svg
src/App.css
# Logs
logs/

133
README.md
View File

@ -1,7 +1,132 @@
# Tauri + React + Typescript
<p align="center">
<img src="public/logo_tougli.png" alt="TougliGui Logo" width="80"/>
</p>
This template should help get you started developing with Tauri, React and Typescript in Vite.
<h1 align="center">TougliGui</h1>
## Recommended IDE Setup
<p align="center">
Tracker de progression pour les guides Dofus [Tougli](https://docs.google.com/spreadsheets/d/1uL7svJ0E0MjhqHVLU7O4Q8v7iGwPd4bsI9qV-Pdhdds/edit?gid=0#gid=0 "lien du guide Tougli") — application desktop légère et hors-ligne
</p>
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
<p align="center">
<img src="https://img.shields.io/badge/Tauri-2.x-blue?logo=tauri" />
<img src="https://img.shields.io/badge/React-19-61DAFB?logo=react" />
<img src="https://img.shields.io/badge/TypeScript-5.8-3178C6?logo=typescript" />
<img src="https://img.shields.io/badge/SQLite-local-003B57?logo=sqlite" />
<img src="https://img.shields.io/github/license/Blomios/TougliGui" />
</p>
---
## Aperçu
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
![Page principale](screenshots/accueil.png)
La page d'accueil affiche la progression globale (quêtes complétées / total) ainsi que les guides en cours sous forme de grilles avec barre de progression individuelle.
### Détail d'un guide
![Guide Dofus Argenté — onglet Ressources](screenshots/guide-argenté.png)
Chaque guide affiche :
- L'effet du Dofus
- La légende des icônes de quêtes (Bashing, Solo, Donjons, Groupe)
- La liste des quêtes organisées par zone, avec indication des quêtes complétées (barrées)
- Un panneau latéral **Ressources** listant les matériaux à collecter avec quantités possédées / requises
### Détail d'une quête
![Détail de quête avec fenêtre image](screenshots/quete-detail.png)
Le détail d'une quête présente chaque étape sous forme de cases à cocher. Une fenêtre flottante **Image** peut être ouverte pour afficher les recettes ou visuels de craft directement depuis la page Dofus Pour Les Noobs, sans quitter l'application.
### Paramètres
![Page paramètres](screenshots/settings.png)
La page paramètres permet de :
- Créer et supprimer des **profils** (un profil = une progression indépendante)
- Changer le profil actif
- **Synchroniser** les guides depuis Google Sheets en un clic
---
## Fonctionnalités
- Suivi de progression par quête et par étape
- Gestion multi-profils (plusieurs personnages)
- Synchronisation des guides depuis Google Sheets
- Inventaire de ressources avec saisie des quantités possédées
- Fenêtre image intégrée pour consulter les recettes sans changer de fenêtre
- Lien direct vers Dofus Pour Les Noobs pour chaque quête
- Données 100 % locales (SQLite), aucun compte requis
- Interface sombre compacte, toujours au premier plan (optionnel)
---
## Stack technique
| Couche | Technologie |
|---|---|
| Framework desktop | [Tauri v2](https://tauri.app/) (Rust) |
| Frontend | React 19 + TypeScript + Vite |
| Styles | Tailwind CSS v4 |
| État global | Zustand |
| Requêtes async | TanStack Query |
| Base de données | SQLite via `tauri-plugin-sql` + `rusqlite` |
| Navigation | React Router v7 |
---
## Installation
### Prérequis
- [Node.js](https://nodejs.org/) ≥ 18
- [Rust](https://rustup.rs/) (stable)
- Dépendances système Linux : `libwebkit2gtk-4.1`, `libgtk-3`, `libayatana-appindicator3`
```bash
# Ubuntu / Debian
sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev
```
### Lancement en développement
```bash
npm install
npm run tauri dev
```
### Build de production
```bash
npm run tauri build
```
Les binaires sont générés dans `src-tauri/target/release/bundle/`.
---
## Téléchargement
Les binaires compilés (Linux `.AppImage` / Windows `.exe`) sont disponibles dans les [Releases GitHub](../../releases).
---
## Premiers pas
1. Lancer l'application — une base de données locale est créée automatiquement.
2. Aller dans **Paramètres** (icône engrenage) → créer un profil.
3. Cliquer sur **Synchroniser maintenant** pour télécharger les guides depuis Google Sheets.
4. Retourner sur la page principale et ouvrir un guide pour commencer à cocher les quêtes.
---
## Licence
MIT — voir [LICENSE](LICENSE).

View File

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/logo_tougli.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TougliGui</title>
</head>

787
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -24,7 +24,7 @@
"@tauri-apps/cli": "^2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"@vitejs/plugin-react": "^6.0.1",
"esbuild": "^0.28.0",
"tailwindcss": "^4.2.4",
"typescript": "~5.8.3",

View File

@ -1,6 +0,0 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,61 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION="${1:-}"
if [[ -z "$VERSION" ]]; then
CURRENT=$(python3 -c "import json; print(json.load(open('src-tauri/tauri.conf.json'))['version'])")
echo "Version actuelle : $CURRENT"
read -rp "Nouvelle version (ex: 1.0.0) : " VERSION
fi
# Retire un éventuel 'v' préfixé
VERSION="${VERSION#v}"
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Erreur : version invalide '$VERSION' (format attendu : X.Y.Z)"
exit 1
fi
echo "→ Mise à jour de la version vers $VERSION"
# Mise à jour tauri.conf.json
python3 - "$VERSION" << 'EOF'
import json, sys
v = sys.argv[1]
path = "src-tauri/tauri.conf.json"
with open(path) as f:
data = json.load(f)
data["version"] = v
with open(path, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
EOF
# Mise à jour package.json
python3 - "$VERSION" << 'EOF'
import json, sys
v = sys.argv[1]
path = "package.json"
with open(path) as f:
data = json.load(f)
data["version"] = v
with open(path, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
EOF
echo "→ Commit de version"
git add src-tauri/tauri.conf.json package.json
git commit -m "chore: bump version to $VERSION"
echo "→ Création du tag v$VERSION"
git tag "v$VERSION"
echo "→ Push vers Gitea"
git push origin main
git push origin "v$VERSION"
echo ""
echo "✓ Tag v$VERSION poussé — le workflow Gitea Actions va builder et créer la release automatiquement."
echo " Suivi : https://gitea.anthonybouteiller.ovh/blomios/TougliGui/actions"

8
run.sh
View File

@ -1,8 +0,0 @@
#!/bin/bash
export PATH="/home/anthony/.config/JetBrains/WebStorm2026.1/node/versions/24.14.1/bin:$HOME/.cargo/bin:$PATH"
export DISPLAY=:0
export GDK_BACKEND=x11
export WEBKIT_DISABLE_DMABUF_RENDERER=1
cd "$(dirname "$0")"
exec npm run tauri dev

BIN
screenshots/accueil.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

BIN
screenshots/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

120
src-tauri/Cargo.lock generated
View File

@ -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"
@ -5589,8 +5700,10 @@ dependencies = [
"chrono",
"csv",
"dirs-next",
"gtk",
"reqwest 0.12.28",
"rusqlite",
"scraper",
"serde",
"serde_json",
"tauri",
@ -5600,6 +5713,7 @@ dependencies = [
"tauri-plugin-sql",
"tokio",
"uuid",
"webkit2gtk",
]
[[package]]
@ -5804,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"

View File

@ -26,3 +26,8 @@ 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"] }
gtk = "0.18"

View File

@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"windows": ["main", "image-viewer"],
"permissions": [
"core:default",
"opener:default",
@ -21,6 +21,7 @@
"core:window:allow-set-cursor-visible",
"core:window:allow-is-maximized",
"core:window:allow-is-minimized",
"core:window:allow-is-focused"
"core:window:allow-is-focused",
"core:window:allow-start-resize-dragging"
]
}

View File

@ -1,4 +1,5 @@
use tauri::{AppHandle, Manager, State};
use tauri::{AppHandle, Emitter, Manager, State};
use tauri::window::Color;
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
use rusqlite::Connection;
@ -173,6 +174,18 @@ pub fn has_guides(state: State<DbState>) -> Result<bool, String> {
Ok(!guides.is_empty())
}
#[tauri::command]
pub fn get_resource_inventory(state: State<DbState>, profile_id: String) -> Result<Vec<(String, i64)>, String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
db::get_resource_inventory(&conn, &profile_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_resource_quantity(state: State<DbState>, profile_id: String, resource_name: String, quantity: i64) -> Result<(), String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
db::set_resource_quantity(&conn, &profile_id, &resource_name, quantity).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_setting(state: State<DbState>, key: String) -> Result<Option<String>, String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
@ -194,6 +207,355 @@ pub fn set_always_on_top(app: AppHandle, value: bool) -> Result<(), String> {
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct QuestStep {
pub index: usize,
pub text: String,
pub images: Vec<String>,
pub launch_position: Option<String>,
}
#[tauri::command]
pub async fn fetch_quest_detail(url: String) -> Result<Vec<QuestStep>, String> {
tokio::task::spawn_blocking(move || {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(20))
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
.build()
.map_err(|e| e.to_string())?;
let html = client.get(&url)
.send()
.map_err(|e| format!("Erreur réseau : {}", e))?
.text()
.map_err(|e| e.to_string())?;
parse_quest_steps(&html)
}).await.map_err(|e| e.to_string())?
}
fn parse_quest_steps(html: &str) -> Result<Vec<QuestStep>, String> {
use scraper::{Html, Selector};
const BASE_URL: &str = "https://www.dofuspourlesnoobs.com";
let document = Html::parse_document(html);
let children_sel = Selector::parse("#wsite-content > div").unwrap();
let para_sel = Selector::parse("div.paragraph, div.paragraphe").unwrap();
let img_link_sel = Selector::parse("div.wsite-image a, div.wsite-image img").unwrap();
// Blue = Position de lancement label; red = other info labels (Prérequis, Niveau…)
let position_sel = Selector::parse("font[color='#3a96b8'], font[color='#3A96B8']").unwrap();
let info_block_sel = Selector::parse(
"font[color='#3a96b8'], font[color='#3A96B8'], font[color='#ff0000'], font[color='#FF0000']"
).unwrap();
let mut steps: Vec<QuestStep> = Vec::new();
let mut first = true;
let mut header_done = false;
for child in document.select(&children_sel) {
if first { first = false; continue; }
let v = child.value();
// ── Case A: the child IS itself a paragraph ──────────────────────────
if v.classes().any(|c| c == "paragraph" || c == "paragraphe") {
let text = element_to_text(&child).trim().to_string();
if text.is_empty() || is_date_meta(&text) { continue; }
if !header_done {
header_done = true;
// Info block detected by colored font labels typical of DPLN info sections
if child.select(&info_block_sel).next().is_some() {
let pos = extract_launch_position(&child, &position_sel);
if pos.is_some() {
let images = collect_images_from(&child, &img_link_sel, BASE_URL);
steps.push(QuestStep { index: steps.len(), text: String::new(), images, launch_position: pos });
}
continue; // always skip as a regular step
}
}
steps.push(QuestStep { index: steps.len(), text, images: vec![], launch_position: None });
continue;
}
// ── Cases B & C: the child is a wrapper div ───────────────────────
let inner_paras: Vec<_> = child.select(&para_sel).collect();
let images = collect_images_from(&child, &img_link_sel, BASE_URL);
if inner_paras.is_empty() {
if !images.is_empty() {
if let Some(last) = steps.last_mut() {
last.images.extend(images);
}
}
} else {
let mut first_para = true;
for para in &inner_paras {
let text = element_to_text(para).trim().to_string();
if text.is_empty() || is_date_meta(&text) { continue; }
if !header_done && first_para {
header_done = true;
if para.select(&info_block_sel).next().is_some() {
let pos = extract_launch_position(para, &position_sel);
if pos.is_some() {
let imgs = images.clone();
steps.push(QuestStep { index: steps.len(), text: String::new(), images: imgs, launch_position: pos });
}
first_para = false;
continue;
}
}
let imgs = if first_para { images.clone() } else { vec![] };
first_para = false;
steps.push(QuestStep { index: steps.len(), text, images: imgs, launch_position: None });
}
}
}
Ok(steps)
}
fn extract_launch_position(para: &scraper::ElementRef, position_sel: &scraper::Selector) -> Option<String> {
for font_el in para.select(position_sel) {
let label: String = font_el.text().collect();
if !label.to_lowercase().contains("position") {
continue;
}
let tree = font_el.tree();
let para_id = para.id();
let mut current_id = font_el.id();
// Walk up from font_el toward para, collecting text from siblings after each ancestor.
// Necessary because the value <span> is a sibling of <strong> (font's grandparent),
// not a sibling of <font> itself.
loop {
let current_node = tree.get(current_id)?;
let parent_node = current_node.parent()?;
let parent_id = parent_node.id();
let mut after_current = false;
let mut result = String::new();
for sibling in parent_node.children() {
if sibling.id() == current_id {
after_current = true;
continue;
}
if !after_current { continue; }
// A new <strong> means a new field — stop collecting
if let scraper::Node::Element(e) = sibling.value() {
if e.name() == "strong" { break; }
}
if let Some(elem) = scraper::ElementRef::wrap(sibling) {
let t: String = elem.text().collect();
let t = t.trim().to_string();
if !t.is_empty() {
if !result.is_empty() { result.push(' '); }
result.push_str(&t);
}
} else if let scraper::Node::Text(t) = sibling.value() {
let s = t.text.trim();
if !s.is_empty() {
if !result.is_empty() { result.push(' '); }
result.push_str(s);
}
}
}
let pos = result.trim().trim_end_matches('.').trim().to_string();
if !pos.is_empty() {
return Some(pos);
}
if parent_id == para_id {
break;
}
current_id = parent_id;
}
}
None
}
fn collect_images_from(
el: &scraper::ElementRef,
img_link_sel: &scraper::Selector,
base_url: &str,
) -> Vec<String> {
let mut images = Vec::new();
let mut seen = std::collections::HashSet::new();
for img_el in el.select(img_link_sel) {
let url_opt = img_el.value().attr("href")
.filter(|u| is_image_url(u))
.or_else(|| img_el.value().attr("src").filter(|u| is_image_url(u)));
if let Some(url) = url_opt {
let absolute = if url.starts_with('/') {
format!("{}{}", base_url, url)
} else {
url.to_string()
};
if seen.insert(absolute.clone()) {
images.push(absolute);
}
}
}
images
}
fn is_date_meta(text: &str) -> bool {
let lower = text.to_lowercase();
// Matches Weebly blog metadata lines like "Publié le 01/01/2024" or "Mis à jour le …"
let is_meta_word = lower.starts_with("publi")
|| lower.starts_with("mis à jour")
|| lower.starts_with("mis en ligne")
|| lower.starts_with("modifi")
|| lower.starts_with("rédigé")
|| lower.starts_with("redige");
// Also catch bare date lines like "01/01/2024" or "2024-01-01"
let digit_count = text.chars().filter(|c| c.is_ascii_digit()).count();
let sep_count = text.chars().filter(|&c| c == '/' || c == '-').count();
let is_bare_date = text.len() < 40 && digit_count >= 6 && sep_count >= 2;
is_meta_word || is_bare_date
}
fn is_image_url(url: &str) -> bool {
url.contains("uploads")
|| url.ends_with(".jpg")
|| url.ends_with(".jpeg")
|| url.ends_with(".png")
|| url.ends_with(".webp")
}
fn element_to_text(el: &scraper::ElementRef) -> String {
let mut out = String::new();
for node in el.descendants() {
match node.value() {
scraper::Node::Text(t) => {
let s = t.text.trim();
if !s.is_empty() {
if !out.is_empty() && !out.ends_with('\n') && !out.ends_with(' ') {
out.push(' ');
}
out.push_str(s);
}
}
scraper::Node::Element(e) => {
if matches!(e.name(), "script" | "style" | "noscript") { continue; }
if matches!(e.name(), "p" | "br" | "h1" | "h2" | "h3" | "h4" | "li" | "div" | "tr") {
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
}
}
_ => {}
}
}
// Collapse multiple blank lines
let mut result = String::new();
let mut prev_empty = false;
for line in out.lines() {
let t = line.trim();
if t.is_empty() {
if !prev_empty { result.push('\n'); }
prev_empty = true;
} else {
result.push_str(t);
result.push('\n');
prev_empty = false;
}
}
result.trim().to_string()
}
#[tauri::command]
pub fn get_completed_steps(
state: State<DbState>,
profile_id: String,
quest_name: String,
) -> Result<Vec<i64>, String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
db::get_completed_steps(&conn, &profile_id, &quest_name).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn toggle_quest_step(
state: State<DbState>,
profile_id: String,
quest_name: String,
step_index: i64,
) -> Result<bool, String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
db::toggle_quest_step(&conn, &profile_id, &quest_name, step_index).map_err(|e| e.to_string())
}
fn percent_encode(s: &str) -> String {
let mut result = String::new();
for byte in s.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
result.push(byte as char);
}
b => result.push_str(&format!("%{:02X}", b)),
}
}
result
}
#[tauri::command]
pub async fn open_image_viewer(
app: AppHandle,
state: State<'_, DbState>,
image_url: String,
) -> Result<(), String> {
if let Some(win) = app.get_webview_window("image-viewer") {
win.emit("set-viewer-image", &image_url).map_err(|e| e.to_string())?;
win.set_focus().map_err(|e| e.to_string())?;
return Ok(());
}
let (w, h, x, y) = {
let conn = state.0.lock().map_err(|e| e.to_string())?;
let w: f64 = db::get_setting(&conn, "viewer_width").and_then(|v| v.parse().ok()).unwrap_or(600.0);
let h: f64 = db::get_setting(&conn, "viewer_height").and_then(|v| v.parse().ok()).unwrap_or(500.0);
let x: Option<f64> = db::get_setting(&conn, "viewer_x").and_then(|v| v.parse().ok());
let y: Option<f64> = db::get_setting(&conn, "viewer_y").and_then(|v| v.parse().ok());
(w, h, x, y)
};
let path = format!("/?viewer=1&imageUrl={}", percent_encode(&image_url));
let mut builder = tauri::WebviewWindowBuilder::new(
&app,
"image-viewer",
tauri::WebviewUrl::App(path.into()),
)
.title("Image")
.decorations(false)
.resizable(true)
.always_on_top(true)
.background_color(Color(13, 17, 23, 255))
.inner_size(w, h);
if let (Some(x), Some(y)) = (x, y) {
builder = builder.position(x, y);
}
let viewer = builder.build().map_err(|e| e.to_string())?;
viewer.eval(r#"(function(){
var s=document.createElement('style');
s.textContent='::-webkit-scrollbar{display:none!important;width:0!important;height:0!important}*{scrollbar-width:none!important}';
var apply=function(){if(document.head)document.head.appendChild(s)};
if(document.head)apply();else document.addEventListener('DOMContentLoaded',apply);
})();"#).ok();
Ok(())
}
fn collect_quest_names(data: &parser::GuideData) -> Vec<String> {
let mut names = Vec::new();
for section in &data.sections {

View File

@ -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<Vec<GuideRow>> {
rows.collect()
}
pub fn get_completed_steps(conn: &Connection, profile_id: &str, quest_name: &str) -> Result<Vec<i64>> {
let mut stmt = conn.prepare(
"SELECT step_index FROM quest_step_progress WHERE profile_id = ?1 AND quest_name = ?2"
)?;
let rows = stmt.query_map(params![profile_id, quest_name], |row| row.get(0))?;
rows.collect()
}
pub fn toggle_quest_step(conn: &Connection, profile_id: &str, quest_name: &str, step_index: i64) -> Result<bool> {
let exists: bool = conn.query_row(
"SELECT COUNT(*) FROM quest_step_progress WHERE profile_id = ?1 AND quest_name = ?2 AND step_index = ?3",
params![profile_id, quest_name, step_index],
|row| row.get::<_, i64>(0),
).map(|c| c > 0)?;
if exists {
conn.execute(
"DELETE FROM quest_step_progress WHERE profile_id = ?1 AND quest_name = ?2 AND step_index = ?3",
params![profile_id, quest_name, step_index],
)?;
Ok(false)
} else {
conn.execute(
"INSERT INTO quest_step_progress (profile_id, quest_name, step_index) VALUES (?1, ?2, ?3)",
params![profile_id, quest_name, step_index],
)?;
Ok(true)
}
}
pub fn get_resource_inventory(conn: &Connection, profile_id: &str) -> Result<Vec<(String, i64)>> {
let mut stmt = conn.prepare(
"SELECT resource_name, quantity FROM resource_inventory WHERE profile_id = ?1"
)?;
let rows = stmt.query_map(params![profile_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
rows.collect()
}
pub fn set_resource_quantity(conn: &Connection, profile_id: &str, resource_name: &str, quantity: i64) -> Result<()> {
if quantity <= 0 {
conn.execute(
"DELETE FROM resource_inventory WHERE profile_id = ?1 AND resource_name = ?2",
params![profile_id, resource_name],
)?;
} else {
conn.execute(
"INSERT INTO resource_inventory (profile_id, resource_name, quantity) VALUES (?1, ?2, ?3)
ON CONFLICT(profile_id, resource_name) DO UPDATE SET quantity=excluded.quantity",
params![profile_id, resource_name, quantity],
)?;
}
Ok(())
}
pub fn get_setting(conn: &Connection, key: &str) -> Option<String> {
conn.query_row(
"SELECT value FROM settings WHERE key = ?1",

View File

@ -14,6 +14,29 @@ pub fn run() {
let conn = db::open().expect("Failed to open database");
db::migrate(&conn).expect("Failed to migrate database");
app.manage(DbState(Mutex::new(conn)));
#[cfg(target_os = "linux")]
{
use gtk::prelude::*;
use webkit2gtk::WebViewExt;
let window = app.get_webview_window("main").expect("no main window");
window.eval(r#"(function(){
var s=document.createElement('style');
s.textContent='*:not(.with-scrollbar)::-webkit-scrollbar{display:none!important;width:0!important;height:0!important}*:not(.with-scrollbar){scrollbar-width:none!important}';
var apply=function(){if(document.head)document.head.appendChild(s)};
if(document.head)apply();else document.addEventListener('DOMContentLoaded',apply);
})();"#).ok();
window.with_webview(|wv| {
let webkit_view = wv.inner();
if let Some(parent) = webkit_view.parent() {
if let Ok(sw) = parent.downcast::<gtk::ScrolledWindow>() {
sw.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Never);
}
}
}).ok();
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
@ -31,6 +54,12 @@ 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,
commands::open_image_viewer,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@ -15,10 +15,11 @@
"title": "TougliGui",
"width": 1100,
"height": 720,
"minWidth": 800,
"minHeight": 500,
"minWidth": 300,
"minHeight": 400,
"decorations": false,
"transparent": false,
"backgroundColor": "#0d1117",
"alwaysOnTop": true,
"resizable": true
}

View File

@ -1 +0,0 @@
/* Overridden by index.css — kept empty */

View File

@ -1,29 +1,86 @@
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow, LogicalSize } from "@tauri-apps/api/window";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { useStore } from "./store";
import TitleBar from "./components/TitleBar";
import Sidebar from "./components/Sidebar";
import ResizeHandles from "./components/ResizeHandles";
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<string | null>("get_setting", { key: "window_width" }),
invoke<string | null>("get_setting", { key: "window_height" }),
]);
if (savedW && savedH) {
await getCurrentWindow().setSize(new LogicalSize(parseInt(savedW), parseInt(savedH)));
}
await loadProfiles();
const has = await invoke<boolean>("has_guides");
if (!has) {
setNeedsSync(true);
} else {
await loadGuides();
// Restore last viewed guide
const lastGuide = await invoke<string | null>("get_setting", { key: "active_guide" });
if (lastGuide) {
try {
await openGuide(lastGuide);
setResourcesPanelCollapsed(true);
} catch { /* guide may no longer exist */ }
}
}
}
init();
// Persist window size on resize (debounced)
const win = getCurrentWindow();
let debounce: ReturnType<typeof setTimeout> | null = null;
const unlisten = win.onResized(async () => {
if (debounce !== null) clearTimeout(debounce);
debounce = setTimeout(async () => {
const size = await win.innerSize();
const factor = await win.scaleFactor();
const w = Math.round(size.width / factor);
const h = Math.round(size.height / factor);
await invoke("set_setting", { key: "window_width", value: w.toString() });
await invoke("set_setting", { key: "window_height", value: h.toString() });
}, 500);
});
const unlistenFocus = win.listen("tauri://focus", async () => {
const viewer = await WebviewWindow.getByLabel("image-viewer");
if (viewer) {
const isMin = await viewer.isMinimized();
if (isMin) await viewer.unminimize();
}
});
const unlistenBlur = win.listen("tauri://blur", async () => {
const isMin = await win.isMinimized();
if (isMin) {
const viewer = await WebviewWindow.getByLabel("image-viewer");
if (viewer) await viewer.minimize();
}
});
return () => {
unlisten.then(f => f());
unlistenFocus.then(f => f());
unlistenBlur.then(f => f());
};
}, []);
async function handleInitialSync() {
@ -33,16 +90,16 @@ export default function App() {
return (
<div className="app-shell">
<TitleBar onOpenProfiles={() => setShowProfileModal(true)} />
<ResizeHandles />
<TitleBar onOpenSettings={() => setShowSettings(s => !s)} />
<div className="app-body">
<Sidebar />
<main className="app-main">
{view === "home" ? <HomeView /> : <GuideView />}
</main>
</div>
{showProfileModal && <ProfileModal onClose={() => setShowProfileModal(false)} />}
{syncing && <SyncOverlay />}
{showSettings && <SettingsPanel onClose={() => setShowSettings(false)} />}
{syncing && !showSettings && <SyncOverlay />}
{needsSync && (
<div style={{
position: "fixed", inset: 0, background: "rgba(0,0,0,0.85)",
@ -82,7 +139,7 @@ export default function App() {
background: #0d1117; overflow: hidden;
}
.app-body { display: flex; flex: 1; overflow: hidden; }
.app-main { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
.app-main { flex: 1; overflow: hidden; display: flex; flex-direction: column; min-height: 0; }
`}</style>
</div>
);

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -1,63 +1,111 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import { useEffect, useState } from "react";
import { useStore } from "../store";
import { SectionItem, QuestItem } from "../types";
import { SectionItem, QuestItem, CombatType } from "../types";
import QuestDetailPanel from "./QuestDetailPanel";
import { TextWithCoords } from "./TextWithCoords";
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []);
return width;
}
function combatIcon(name: string): string {
const l = name.toLowerCase();
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 { 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;
const { name, effect, recommended_level, resources, sections } = activeGuideData;
if (selectedQuest && activeProfileId) {
return (
<div style={{ flex: 1, display: "flex", overflow: "hidden", minHeight: 0 }}>
<QuestDetailPanel
questName={selectedQuest.name}
questUrl={selectedQuest.url}
profileId={activeProfileId}
onClose={() => setSelectedQuest(null)}
/>
</div>
);
}
const { name, effect, recommended_level, resources, sections, combat_legend } = activeGuideData;
const allQuests = collectAllQuests(sections);
const completedCount = allQuests.filter(q => completedQuests.has(q)).length;
const pct = allQuests.length > 0 ? Math.round((completedCount / allQuests.length) * 100) : 0;
const isDone = pct === 100;
return (
<div style={{ flex: 1, display: "flex", overflow: "hidden" }}>
{/* Main quest list */}
<div style={{ flex: 1, overflowY: "auto", padding: "20px 24px" }}>
{/* Guide header */}
<div style={{ flex: 1, display: "flex", overflow: "hidden", minHeight: 0, position: "relative" }}>
<div className="with-scrollbar" style={{ flex: 1, overflowY: "auto", padding: "20px 24px" }}>
{/* Header */}
<div style={{ marginBottom: "20px" }}>
<h1 style={{ fontSize: "20px", fontWeight: 700, color: "#f0c040", marginBottom: "4px" }}>
{name}
</h1>
<div style={{ display: "flex", gap: "16px", alignItems: "center", marginBottom: "10px" }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: "8px", flexWrap: "wrap" }}>
<div style={{ minWidth: 0 }}>
<h1 style={{ fontSize: "18px", fontWeight: 700, color: "#f0c040", marginBottom: "2px", wordBreak: "break-word" }}>{name}</h1>
{recommended_level && (
<span style={{
fontSize: "11px", background: "rgba(74,158,255,0.15)", color: "#4a9eff",
border: "1px solid rgba(74,158,255,0.3)", borderRadius: "4px", padding: "2px 8px",
}}>
Niveau recommandé : {recommended_level}
</span>
)}
<span style={{ fontSize: "11px", color: "#94a3b8" }}>
{completedCount}/{allQuests.length} quêtes · {pct}%
</span>
<div style={{ fontSize: "12px", color: "#94a3b8" }}>
Niv. recommandé : <span style={{ color: "#e2e8f0", fontWeight: 600 }}>{recommended_level}</span>
</div>
{/* Progress bar */}
<div style={{ height: "4px", background: "#2d3748", borderRadius: "2px", overflow: "hidden" }}>
)}
</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={{
height: "100%", width: `${pct}%`,
background: pct === 100 ? "#4ade80" : "linear-gradient(90deg, #4a9eff, #f0c040)",
background: isDone ? "#4ade80" : "linear-gradient(90deg, #4a9eff, #f0c040)",
borderRadius: "2px", transition: "width 0.3s ease",
}} />
</div>
{effect && (
<div style={{
marginTop: "10px", background: "rgba(240,192,64,0.05)",
border: "1px solid rgba(240,192,64,0.2)", borderRadius: "6px",
padding: "8px 12px", fontSize: "11px", color: "#94a3b8", lineHeight: 1.5,
marginTop: "12px", padding: "10px 14px",
background: "rgba(240,192,64,0.04)", borderRadius: "6px",
borderLeft: "3px solid rgba(240,192,64,0.4)",
fontSize: "12px", color: "#94a3b8", lineHeight: 1.6,
}}>
<span style={{ color: "#f0c040", fontWeight: 600 }}>Effet : </span>
<span style={{ color: "#f0c040", fontWeight: 600, fontSize: "11px", display: "block", marginBottom: "3px", textTransform: "uppercase", letterSpacing: "0.05em" }}>
Effet
</span>
{effect}
</div>
)}
</div>
{/* Légende */}
{combat_legend.length > 0 && (
<Legend legend={combat_legend} />
)}
{/* Sections */}
{sections.map((section, si) => (
<div key={si} style={{ marginBottom: "20px" }}>
<div key={si} style={{ marginBottom: "24px" }}>
<h2 style={{
fontSize: "11px", fontWeight: 600, color: "#4a5568",
textTransform: "uppercase", letterSpacing: "0.1em",
@ -66,7 +114,7 @@ export default function GuideView() {
{section.name}
</h2>
{section.items.map((item, ii) => (
<SectionItemView key={ii} item={item} completedQuests={completedQuests} onToggle={toggleQuest} />
<SectionItemView key={ii} item={item} completedQuests={completedQuests} onToggle={toggleQuest} onSelect={setSelectedQuest} />
))}
</div>
))}
@ -75,35 +123,144 @@ export default function GuideView() {
{/* Resources panel */}
{resources.length > 0 && (
<div style={{
width: "200px", flexShrink: 0, background: "#161b22",
borderLeft: "1px solid #2d3748", overflowY: "auto", padding: "16px 14px",
position: resourcesIsOverlay ? "absolute" : "relative",
right: 0,
top: 0,
bottom: 0,
zIndex: 10,
width: resourcesCollapsed ? "36px" : "190px",
flexShrink: 0,
background: resourcesCollapsed ? "transparent" : "#161b22",
borderLeft: resourcesCollapsed ? "none" : "1px solid #2d3748",
display: "flex",
flexDirection: "column",
overflow: "hidden",
transition: "width 0.2s ease, background 0.2s ease",
}}>
<h3 style={{
fontSize: "11px", fontWeight: 600, color: "#4a5568",
textTransform: "uppercase", letterSpacing: "0.1em", marginBottom: "10px",
{/* Toggle */}
<button
onClick={() => setResourcesCollapsed(!resourcesCollapsed)}
title={resourcesCollapsed ? "Afficher les ressources" : "Masquer les ressources"}
style={{
width: "100%",
height: "36px",
flexShrink: 0,
background: resourcesCollapsed ? "rgba(22,27,34,0.9)" : "transparent",
border: resourcesCollapsed ? "1px solid #2d3748" : "none",
borderRight: "none",
borderRadius: resourcesCollapsed ? "6px 0 0 6px" : "0",
borderBottom: "1px solid #2d3748",
color: "#4a5568",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: resourcesCollapsed ? "center" : "flex-start",
padding: "0 10px",
gap: "6px",
marginTop: resourcesCollapsed ? "8px" : "0",
transition: "all 0.15s",
}}
onMouseEnter={e => (e.currentTarget.style.color = "#f0c040")}
onMouseLeave={e => (e.currentTarget.style.color = "#4a5568")}
>
<span style={{
fontSize: "12px",
transform: resourcesCollapsed ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.2s ease",
display: "inline-block",
}}>
</span>
{!resourcesCollapsed && (
<span style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.1em", whiteSpace: "nowrap" }}>
Ressources
</h3>
{resources.map((r, i) => (
</span>
)}
</button>
{/* List */}
{!resourcesCollapsed && (
<div className="with-scrollbar" style={{ flex: 1, overflowY: "auto", padding: "10px 14px" }}>
{resources.map((r, i) => {
const owned = resourceInventory[r.name] ?? 0;
const done = owned >= r.quantity;
return (
<div key={i} style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "4px 0", borderBottom: "1px solid #1f2937",
fontSize: "11px",
padding: "6px 0", borderBottom: "1px solid #1f2937", fontSize: "12px",
}}>
<span style={{ color: "#94a3b8", flex: 1, marginRight: "8px" }}>{r.name}</span>
<span style={{ color: "#f0c040", fontWeight: 700, flexShrink: 0 }}>×{r.quantity}</span>
<span style={{
color: done ? "#4ade80" : "#94a3b8",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
wordBreak: "break-word",
marginBottom: "3px",
} as React.CSSProperties}>
{r.name}
</span>
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<input
type="number"
min="0"
value={owned === 0 ? "" : owned}
placeholder="0"
onChange={e => {
const v = parseInt(e.target.value);
setResourceQuantity(r.name, isNaN(v) ? 0 : Math.max(0, v));
}}
style={{
width: "42px", background: "#0d1117",
border: `1px solid ${done ? "rgba(74,222,128,0.4)" : "#2d3748"}`,
borderRadius: "4px", padding: "2px 4px",
color: done ? "#4ade80" : "#e2e8f0",
fontSize: "11px", outline: "none", textAlign: "right",
}}
/>
<span style={{ color: done ? "#4ade80" : "#f0c040", fontWeight: 700, flexShrink: 0 }}>
/ ×{r.quantity}
</span>
</div>
))}
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
}
function SectionItemView({ item, completedQuests, onToggle }: {
function Legend({ legend }: { legend: CombatType[] }) {
return (
<div style={{
marginBottom: "20px", padding: "12px 16px",
background: "rgba(255,255,255,0.02)", border: "1px solid #2d3748", borderRadius: "8px",
}}>
<div style={{
fontSize: "11px", fontWeight: 600, color: "#4a5568",
textTransform: "uppercase", letterSpacing: "0.1em", marginBottom: "10px",
}}>
Légende
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "16px" }}>
{legend.map((ct, i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<span style={{ fontSize: "15px" }}>{combatIcon(ct.name)}</span>
<span style={{ fontSize: "12px", color: "#94a3b8" }}>{ct.name}</span>
</div>
))}
</div>
</div>
);
}
function SectionItemView({ item, completedQuests, onToggle, onSelect }: {
item: SectionItem;
completedQuests: Set<string>;
onToggle: (name: string) => void;
onSelect: (quest: { name: string; url: string | null }) => void;
}) {
if (item.type === "Instruction") {
if (item.text.startsWith("__ZONE__:")) {
@ -121,11 +278,16 @@ function SectionItemView({ item, completedQuests, onToggle }: {
}
return (
<div style={{
background: "rgba(74,158,255,0.05)", border: "1px solid rgba(74,158,255,0.15)",
borderRadius: "6px", padding: "8px 12px", marginBottom: "4px",
fontSize: "11px", color: "#94a3b8", lineHeight: 1.5,
marginBottom: "6px", padding: "9px 14px",
background: "rgba(74,158,255,0.04)",
borderLeft: "3px solid rgba(74,158,255,0.35)",
borderRadius: "4px",
fontSize: "12px", color: "#94a3b8", lineHeight: 1.6,
}}>
{item.text}
<span style={{ color: "#4a9eff", fontSize: "10px", fontWeight: 600, display: "block", marginBottom: "2px", textTransform: "uppercase", letterSpacing: "0.05em" }}>
Rappel
</span>
<TextWithCoords text={item.text} />
</div>
);
}
@ -137,98 +299,74 @@ function SectionItemView({ item, completedQuests, onToggle }: {
borderRadius: "6px", padding: "8px 10px", marginBottom: "6px",
}}>
{item.note && (
<div style={{
fontSize: "10px", color: "#4a9eff", marginBottom: "6px",
fontStyle: "italic",
}}>
<div style={{ fontSize: "11px", color: "#4a9eff", marginBottom: "6px", fontStyle: "italic" }}>
🔗 {item.note}
</div>
)}
{item.quests.map((q, i) => (
<QuestRow key={i} quest={q} indent completed={completedQuests.has(q.name)} onToggle={onToggle} />
<QuestRow key={i} quest={q} indent completed={completedQuests.has(q.name)} onToggle={onToggle} onSelect={onSelect} />
))}
</div>
);
}
if (item.type === "Quest") {
return <QuestRow quest={item} completed={completedQuests.has(item.name)} onToggle={onToggle} />;
return <QuestRow quest={item} completed={completedQuests.has(item.name)} onToggle={onToggle} onSelect={onSelect} />;
}
return null;
}
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 (
<div
onClick={() => onToggle(quest.name)}
style={{
display: "flex", alignItems: "flex-start", gap: "8px",
padding: indent ? "4px 0" : "5px 6px",
borderRadius: "5px", cursor: "pointer",
marginBottom: indent ? "2px" : "3px",
opacity: completed ? 0.6 : 1,
padding: indent ? "3px 0" : "4px 6px",
borderRadius: "5px",
marginBottom: indent ? "1px" : "2px",
opacity: completed ? 0.5 : 1,
transition: "all 0.12s",
}}
onMouseEnter={e => {
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.04)";
}}
onMouseLeave={e => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.04)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<input
type="checkbox"
checked={completed}
onChange={() => onToggle(quest.name)}
onClick={e => e.stopPropagation()}
style={{ marginTop: "2px", flexShrink: 0 }}
style={{ marginTop: "2px", flexShrink: 0, cursor: "pointer" }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: "5px", flexWrap: "wrap" }}>
<span style={{
fontSize: "12px", color: completed ? "#4a5568" : "#e2e8f0",
textDecoration: completed ? "line-through" : "none",
lineHeight: 1.4,
}}>
<div style={{ display: "flex", alignItems: "baseline", flexWrap: "wrap", gap: "2px 8px" }}>
<span
onClick={() => 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}
</span>
{quest.url && (
<span
title="Voir sur Dofus Pour Les Noobs"
onClick={e => { e.stopPropagation(); openUrl(quest.url!); }}
style={{
fontSize: "10px", color: "#4a9eff", cursor: "pointer",
opacity: 0.7, flexShrink: 0, lineHeight: 1,
}}
onMouseEnter={e => (e.currentTarget as HTMLElement).style.opacity = "1"}
onMouseLeave={e => (e.currentTarget as HTMLElement).style.opacity = "0.7"}
>
🔗
</span>
)}
</div>
{quest.combat_indicators.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: "3px", marginTop: "3px" }}>
{quest.combat_indicators.map((ci, i) => (
<span key={i} style={{
fontSize: "9px", padding: "1px 5px",
background: "rgba(74,158,255,0.15)", color: "#4a9eff",
border: "1px solid rgba(74,158,255,0.25)", borderRadius: "3px",
}}>
{ci.combat_type} {ci.count}
<span key={i} style={{ fontSize: "11px", color: "#94a3b8", whiteSpace: "nowrap", flexShrink: 0 }}>
{combatIcon(ci.combat_type)} x{ci.count}
</span>
))}
</div>
)}
{quest.note && (
<div style={{ fontSize: "10px", color: "#94a3b8", marginTop: "2px", fontStyle: "italic" }}>
{quest.note}
<div style={{ fontSize: "11px", color: "#4a5568", marginTop: "2px", fontStyle: "italic" }}>
<TextWithCoords text={quest.note} />
</div>
)}
</div>
@ -236,13 +374,12 @@ function QuestRow({ quest, completed, onToggle, indent }: {
);
}
function collectAllQuests(sections: import("../types").Section[] | undefined): string[] {
if (!sections) return [];
function collectAllQuests(sections: import("../types").Section[]): string[] {
const names: string[] = [];
for (const section of sections) {
for (const item of section.items) {
if (item.type === "Quest") names.push(item.name);
else if (item.type === "Group") item.quests.forEach((q: import("../types").QuestItem) => names.push(q.name));
else if (item.type === "Group") item.quests.forEach((q: QuestItem) => names.push(q.name));
}
}
return names;

View File

@ -4,19 +4,17 @@ export default function HomeView() {
const { guides, openGuide, profiles, activeProfileId } = useStore();
const activeProfile = profiles.find(p => p.id === activeProfileId);
const totalQuests = guides.reduce((s, g) => s + g.total_quests, 0);
const totalCompleted = guides.reduce((s, g) => s + g.completed_quests, 0);
const globalPct = totalQuests > 0 ? Math.round((totalCompleted / totalQuests) * 100) : 0;
const completedGuides = guides.filter(g => g.total_quests > 0 && g.completed_quests === g.total_quests);
const inProgressGuides = guides.filter(g => g.completed_quests > 0 && g.completed_quests < g.total_quests);
return (
<div style={{ flex: 1, overflowY: "auto", padding: "20px 24px" }}>
<div className="with-scrollbar" style={{ flex: 1, overflowY: "auto", padding: "20px 24px", minHeight: 0 }}>
{/* Header */}
<div style={{ marginBottom: "24px" }}>
<h1 style={{ fontSize: "18px", fontWeight: 700, color: "#f0c040", marginBottom: "4px" }}>
<div style={{ marginBottom: "20px" }}>
<h1 style={{ fontSize: "20px", fontWeight: 700, color: "#f0c040", marginBottom: "2px" }}>
Tougli Guide Dofus
</h1>
{activeProfile && (
@ -32,7 +30,7 @@ export default function HomeView() {
background: "#161b22", border: "1px solid #2d3748", borderRadius: "10px",
padding: "16px 20px", marginBottom: "24px",
}}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "8px" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "8px" }}>
<span style={{ fontSize: "13px", color: "#94a3b8" }}>Progression globale</span>
<span style={{ fontSize: "13px", fontWeight: 700, color: "#f0c040" }}>
{totalCompleted} / {totalQuests} quêtes ({globalPct}%)
@ -45,7 +43,7 @@ export default function HomeView() {
borderRadius: "3px", transition: "width 0.4s ease",
}} />
</div>
<div style={{ marginTop: "8px", display: "flex", gap: "16px" }}>
<div style={{ marginTop: "10px", display: "flex", gap: "20px" }}>
<Stat label="Complétés" value={completedGuides.length} color="#4ade80" />
<Stat label="En cours" value={inProgressGuides.length} color="#f0c040" />
<Stat label="Total" value={guides.length} color="#94a3b8" />
@ -53,12 +51,12 @@ export default function HomeView() {
</div>
)}
{/* In progress first */}
{/* En cours */}
{inProgressGuides.length > 0 && (
<Section title="En cours" guides={inProgressGuides} onOpen={openGuide} />
)}
{/* All guides grid */}
{/* Tous les guides */}
<Section title="Tous les guides" guides={guides} onOpen={openGuide} />
</div>
);
@ -66,9 +64,9 @@ export default function HomeView() {
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
<div>
<span style={{ fontSize: "16px", fontWeight: 700, color }}>{value}</span>
<span style={{ fontSize: "11px", color: "#4a5568" }}>{label}</span>
<span style={{ fontSize: "11px", color: "#4a5568", marginLeft: "4px" }}>{label}</span>
</div>
);
}
@ -80,13 +78,15 @@ function Section({ title, guides, onOpen }: {
}) {
return (
<div style={{ marginBottom: "24px" }}>
<h2 style={{ fontSize: "12px", fontWeight: 600, color: "#4a5568", textTransform: "uppercase", letterSpacing: "0.1em", marginBottom: "10px" }}>
<h2 style={{
fontSize: "11px", fontWeight: 600, color: "#4a5568",
textTransform: "uppercase", letterSpacing: "0.1em",
marginBottom: "10px", borderBottom: "1px solid #2d3748", paddingBottom: "4px",
}}>
{title}
</h2>
<div style={{
display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: "8px",
}}>
{guides.map(guide => <GuideCard key={guide.gid} guide={guide} onOpen={onOpen} />)}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(175px, 1fr))", gap: "8px" }}>
{guides.map(g => <GuideCard key={g.gid} guide={g} onOpen={onOpen} />)}
</div>
</div>
);
@ -96,53 +96,64 @@ function GuideCard({ guide, onOpen }: {
guide: import("../types").GuideListItem;
onOpen: (gid: string) => void;
}) {
const pct = guide.total_quests > 0
? Math.round((guide.completed_quests / guide.total_quests) * 100)
: 0;
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 accentColor = isDone ? "#4ade80" : inProgress ? "#f0c040" : "#4a9eff";
return (
<button
onClick={() => onOpen(guide.gid)}
style={{
background: isDone ? "rgba(74,222,128,0.05)" : "#161b22",
border: `1px solid ${isDone ? "rgba(74,222,128,0.3)" : "#2d3748"}`,
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",
textAlign: "left", transition: "all 0.15s", position: "relative", overflow: "hidden",
}}
onMouseEnter={e => {
(e.currentTarget as HTMLElement).style.borderColor = isDone ? "rgba(74,222,128,0.6)" : "#f0c040";
(e.currentTarget as HTMLElement).style.background = isDone ? "rgba(74,222,128,0.08)" : "#1a2233";
(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.3)" : "#2d3748";
(e.currentTarget as HTMLElement).style.background = isDone ? "rgba(74,222,128,0.05)" : "#161b22";
(e.currentTarget as HTMLElement).style.borderColor = isDone ? "rgba(74,222,128,0.25)" : "#2d3748";
(e.currentTarget as HTMLElement).style.background = "#161b22";
}}
>
{/* Indicateur latéral */}
<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" }}>
<span style={{
fontSize: "12px", fontWeight: 600, color: isDone ? "#4ade80" : "#e2e8f0",
lineHeight: 1.3,
fontSize: "12px", fontWeight: 600, lineHeight: 1.3,
color: isDone ? "#4ade80" : "#e2e8f0",
}}>
{guide.name}
</span>
{isDone && <span style={{ fontSize: "14px" }}></span>}
{isDone && <span style={{ fontSize: "12px", flexShrink: 0 }}></span>}
</div>
<div style={{ height: "3px", background: "#2d3748", borderRadius: "2px", overflow: "hidden", marginBottom: "6px" }}>
<div style={{
height: "100%", width: `${pct}%`,
background: isDone ? "#4ade80" : pct > 60 ? "#f0c040" : "#4a9eff",
borderRadius: "2px",
background: accentColor,
borderRadius: "2px", transition: "width 0.3s ease",
}} />
</div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "10px", color: "#4a5568" }}>
{guide.completed_quests}/{guide.total_quests} quêtes
</span>
<span style={{ fontSize: "10px", fontWeight: 700, color: isDone ? "#4ade80" : "#94a3b8" }}>
<span style={{ fontSize: "10px", fontWeight: 700, color: accentColor }}>
{pct}%
</span>
</div>
</div>
</button>
);
}

View File

@ -0,0 +1,74 @@
import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import ResizeHandles from "./ResizeHandles";
export default function ImageViewerWindow() {
const params = new URLSearchParams(window.location.search);
const [imageUrl, setImageUrl] = useState(decodeURIComponent(params.get("imageUrl") ?? ""));
const win = getCurrentWindow();
useEffect(() => {
let debounce: ReturnType<typeof setTimeout> | null = null;
const unlistenImage = win.listen<string>("set-viewer-image", (event) => {
setImageUrl(event.payload);
});
const unlistenResize = win.onResized(async () => {
if (debounce) clearTimeout(debounce);
debounce = setTimeout(async () => {
const [size, factor] = await Promise.all([win.innerSize(), win.scaleFactor()]);
await invoke("set_setting", { key: "viewer_width", value: String(Math.round(size.width / factor)) });
await invoke("set_setting", { key: "viewer_height", value: String(Math.round(size.height / factor)) });
}, 500);
});
const unlistenMove = win.onMoved(async () => {
if (debounce) clearTimeout(debounce);
debounce = setTimeout(async () => {
const [pos, factor] = await Promise.all([win.outerPosition(), win.scaleFactor()]);
await invoke("set_setting", { key: "viewer_x", value: String(Math.round(pos.x / factor)) });
await invoke("set_setting", { key: "viewer_y", value: String(Math.round(pos.y / factor)) });
}, 500);
});
return () => {
unlistenImage.then(f => f());
unlistenResize.then(f => f());
unlistenMove.then(f => f());
};
}, []);
return (
<div style={{ height: "100vh", display: "flex", flexDirection: "column", background: "#0d1117", overflow: "hidden" }}>
<ResizeHandles />
<div
onMouseDown={e => { if (e.button === 0) win.startDragging(); }}
style={{
height: "28px",
background: "#161b22",
borderBottom: "1px solid #2d3748",
cursor: "grab",
flexShrink: 0,
display: "flex",
alignItems: "center",
paddingLeft: "12px",
userSelect: "none",
}}
>
<span style={{ fontSize: "11px", color: "#4a5568", pointerEvents: "none" }}> Image</span>
</div>
<div style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}>
{imageUrl && (
<img
src={imageUrl}
style={{ width: "100%", display: "block" }}
draggable={false}
/>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,298 @@
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";
interface Props {
questName: string;
questUrl: string | null;
profileId: string;
onClose: () => void;
}
export default function QuestDetailPanel({ questName, questUrl, profileId, onClose }: Props) {
const [steps, setSteps] = useState<QuestStep[]>([]);
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
const [expandedSteps, setExpandedSteps] = useState<Set<number>>(new Set());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!questUrl) {
setError("Aucun lien disponible pour cette quête.");
setLoading(false);
return;
}
setLoading(true);
setError(null);
Promise.all([
invoke<QuestStep[]>("fetch_quest_detail", { url: questUrl }),
invoke<number[]>("get_completed_steps", { profileId, questName }),
]).then(([fetchedSteps, completedIndices]) => {
setSteps(fetchedSteps);
setCompletedSteps(new Set(completedIndices));
}).catch(e => {
setError(`Impossible de charger la page : ${e}`);
}).finally(() => setLoading(false));
}, [questUrl, questName, profileId]);
const toggleStep = async (index: number) => {
const isNow = await invoke<boolean>("toggle_quest_step", { profileId, questName, stepIndex: index });
setCompletedSteps(prev => {
const next = new Set(prev);
if (isNow) next.add(index); else next.delete(index);
return next;
});
};
const toggleExpanded = (index: number) => {
setExpandedSteps(prev => {
const next = new Set(prev);
if (next.has(index)) next.delete(index); else next.add(index);
return next;
});
};
const firstIsHeader = steps.length > 0 && steps[0].launch_position != null;
const headerStep = firstIsHeader ? steps[0] : null;
const actionSteps = firstIsHeader ? steps.slice(1) : steps;
const completedCount = actionSteps.filter(s => completedSteps.has(s.index)).length;
const pct = actionSteps.length > 0 ? Math.round((completedCount / actionSteps.length) * 100) : 0;
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", minHeight: 0 }}>
{/* Title bar */}
<div style={{
padding: "12px 16px", borderBottom: "1px solid #2d3748",
background: "#161b22", flexShrink: 0,
}}>
<div style={{ display: "flex", alignItems: "center", gap: "10px", marginBottom: "6px" }}>
<button
onClick={onClose}
style={{
background: "transparent", border: "1px solid #2d3748",
borderRadius: "5px", color: "#94a3b8", cursor: "pointer",
fontSize: "11px", padding: "3px 8px", flexShrink: 0, transition: "all 0.15s",
}}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "#f0c040"; (e.currentTarget as HTMLElement).style.color = "#f0c040"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "#2d3748"; (e.currentTarget as HTMLElement).style.color = "#94a3b8"; }}
>
Retour
</button>
<span style={{ fontSize: "13px", fontWeight: 600, color: "#e2e8f0", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{questName}
</span>
</div>
{actionSteps.length > 0 && (
<div>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: "11px", color: "#94a3b8", marginBottom: "4px" }}>
<span>{completedCount}/{actionSteps.length} étapes</span>
<span style={{ color: pct === 100 ? "#4ade80" : "#f0c040", fontWeight: 600 }}>{pct}%</span>
</div>
<div style={{ height: "3px", background: "#2d3748", borderRadius: "2px", overflow: "hidden" }}>
<div style={{
height: "100%", width: `${pct}%`,
background: pct === 100 ? "#4ade80" : "linear-gradient(90deg, #4a9eff, #f0c040)",
transition: "width 0.3s ease", borderRadius: "2px",
}} />
</div>
</div>
)}
{questUrl && (
<button
onClick={() => openUrl(questUrl)}
style={{
marginTop: "6px", background: "transparent", border: "none",
color: "#4a5568", fontSize: "10px", cursor: "pointer", padding: 0,
textDecoration: "underline",
}}
onMouseEnter={e => (e.currentTarget.style.color = "#93c5fd")}
onMouseLeave={e => (e.currentTarget.style.color = "#4a5568")}
>
Ouvrir sur Dofus Pour Les Noobs
</button>
)}
</div>
{/* Content */}
<div className="with-scrollbar" style={{ flex: 1, overflowY: "auto", padding: "12px 16px" }}>
{loading && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "120px", color: "#4a5568", fontSize: "13px" }}>
<span style={{ animation: "spin 1s linear infinite", display: "inline-block", marginRight: "8px" }}></span>
Chargement
</div>
)}
{error && (
<div style={{
padding: "12px", background: "rgba(248,113,113,0.08)",
border: "1px solid rgba(248,113,113,0.2)", borderRadius: "6px",
color: "#f87171", fontSize: "12px",
}}>
{error}
</div>
)}
{!loading && !error && steps.length === 0 && (
<div style={{ color: "#4a5568", fontSize: "12px", textAlign: "center", paddingTop: "40px" }}>
Aucune étape trouvée sur la page.
</div>
)}
{!loading && headerStep && (
<QuestHeader step={headerStep} />
)}
{!loading && actionSteps.map((step) => {
const done = completedSteps.has(step.index);
const expanded = expandedSteps.has(step.index);
if (done) {
const firstLine = step.text.split('\n').find(l => l.trim().length > 0) ?? step.text;
return (
<div key={step.index} onClick={() => toggleStep(step.index)} style={{
marginBottom: "4px",
padding: "6px 12px",
border: "1px solid rgba(74,222,128,0.1)",
borderRadius: "7px",
background: "rgba(74,222,128,0.03)",
opacity: 0.5,
cursor: "pointer",
}}>
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
<input
type="checkbox"
checked={true}
onChange={() => toggleStep(step.index)}
onClick={e => e.stopPropagation()}
style={{ flexShrink: 0, cursor: "pointer" }}
/>
<span style={{
fontSize: "12px", color: "#4a5568",
textDecoration: "line-through",
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
flex: 1, minWidth: 0,
}}>
{firstLine}
</span>
</div>
</div>
);
}
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={{
marginBottom: "8px",
background: "rgba(255,255,255,0.02)",
border: "1px solid #2d3748",
borderRadius: "7px", padding: "10px 12px",
transition: "all 0.15s",
cursor: "pointer",
}}>
<div style={{ display: "flex", gap: "10px", alignItems: "flex-start" }}>
<input
type="checkbox"
checked={false}
onChange={() => toggleStep(step.index)}
onClick={e => e.stopPropagation()}
style={{ marginTop: "2px", flexShrink: 0, cursor: "pointer" }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: "12px", color: "#94a3b8",
lineHeight: 1.6, whiteSpace: "pre-wrap", wordBreak: "break-word",
}}>
<TextWithCoords text={displayText} />
</div>
{needsTruncate && (
<button
onClick={e => { e.stopPropagation(); toggleExpanded(step.index); }}
style={{
marginTop: "4px", background: "transparent", border: "none",
color: "#4a9eff", fontSize: "11px", cursor: "pointer",
padding: 0, textDecoration: "underline",
}}
>
{expanded ? "Voir moins" : "Voir plus"}
</button>
)}
{step.images.length > 0 && (
<div style={{ marginTop: "8px", display: "flex", flexDirection: "column", gap: "6px" }}>
{step.images.map((src, j) => (
<img
key={j}
src={src}
onClick={e => { e.stopPropagation(); invoke("open_image_viewer", { imageUrl: src }); }}
style={{
maxWidth: "100%", height: "auto",
borderRadius: "6px", display: "block",
border: "1px solid #2d3748", cursor: "pointer",
}}
/>
))}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
function QuestHeader({ step }: { step: QuestStep }) {
return (
<div style={{
marginBottom: "14px",
border: "1px solid #2d3748",
borderRadius: "8px",
overflow: "hidden",
}}>
<div style={{ padding: "10px 14px", display: "flex", alignItems: "baseline", gap: "8px", fontSize: "12px" }}>
<span style={{ fontSize: "13px", flexShrink: 0 }}>📍</span>
<span style={{
color: "#4a5568", fontWeight: 600, fontSize: "10px",
textTransform: "uppercase", letterSpacing: "0.05em",
flexShrink: 0,
}}>
Position
</span>
<span style={{ color: "#cbd5e1", lineHeight: 1.5, wordBreak: "break-word" }}>
<TextWithCoords text={step.launch_position!} />
</span>
</div>
{step.images.length > 0 && (
<div style={{ padding: "0 14px 10px", display: "flex", flexDirection: "column", gap: "6px" }}>
{step.images.map((src, j) => (
<img
key={j}
src={src}
onClick={() => invoke("open_image_viewer", { imageUrl: src })}
style={{
maxWidth: "100%", height: "auto",
borderRadius: "6px", display: "block",
border: "1px solid #2d3748", cursor: "pointer",
}}
/>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,40 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
const S = 8; // grab zone size in px
type ResizeDirection =
| "North"
| "East"
| "South"
| "West"
| "NorthEast"
| "SouthEast"
| "SouthWest"
| "NorthWest";
const handles: { edge: ResizeDirection; style: React.CSSProperties }[] = [
{ edge: "North", style: { top: 0, left: S, right: S, height: S, cursor: "n-resize" } },
{ edge: "South", style: { bottom: 0, left: S, right: S, height: S, cursor: "s-resize" } },
{ edge: "West", style: { top: S, left: 0, bottom: S, width: S, cursor: "w-resize" } },
{ edge: "East", style: { top: S, right: 0, bottom: S, width: S, cursor: "e-resize" } },
{ edge: "NorthWest", style: { top: 0, left: 0, width: S, height: S, cursor: "nw-resize" } },
{ edge: "NorthEast", style: { top: 0, right: 0, width: S, height: S, cursor: "ne-resize" } },
{ edge: "SouthWest", style: { bottom: 0, left: 0, width: S, height: S, cursor: "sw-resize" } },
{ edge: "SouthEast", style: { bottom: 0, right: 0,width: S, height: S, cursor: "se-resize" } },
];
export default function ResizeHandles() {
const win = getCurrentWindow();
return (
<>
{handles.map(({ edge, style }) => (
<div
key={edge}
onMouseDown={e => { if (e.button === 0) win.startResizeDragging(edge); }}
style={{ position: "fixed", zIndex: 9999, ...style }}
/>
))}
</>
);
}

View File

@ -0,0 +1,204 @@
import { useState } from "react";
import { useStore } from "../store";
export default function SettingsPanel({ onClose }: { onClose: () => void }) {
const {
profiles, activeProfileId, setActiveProfile, createProfile, deleteProfile,
syncGuides, syncing, syncProgress,
} = useStore();
const [newName, setNewName] = useState("");
const [profileError, setProfileError] = useState("");
const [syncErrors, setSyncErrors] = useState<string[]>([]);
const [syncDone, setSyncDone] = useState(false);
async function handleCreate() {
const name = newName.trim();
if (!name) return;
if (profiles.find(p => p.name === name)) {
setProfileError("Un profil avec ce nom existe déjà.");
return;
}
await createProfile(name);
setNewName("");
setProfileError("");
}
async function handleDelete(id: string) {
if (profiles.length <= 1) {
setProfileError("Vous ne pouvez pas supprimer le dernier profil.");
return;
}
await deleteProfile(id);
}
async function handleSync() {
setSyncErrors([]);
setSyncDone(false);
const result = await syncGuides();
setSyncErrors(result.errors);
setSyncDone(true);
}
const { current = 0, total = 0, label = "" } = syncProgress ?? {};
const syncPct = total > 0 ? Math.round((current / total) * 100) : 0;
return (
<div style={{
position: "fixed", inset: "40px 0 0 0",
background: "#0d1117", zIndex: 50,
display: "flex", flexDirection: "column", overflow: "hidden",
borderTop: "1px solid #2d3748",
}}>
{/* Header */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "14px 20px", borderBottom: "1px solid #2d3748", flexShrink: 0,
}}>
<span style={{ fontSize: "14px", fontWeight: 700, color: "#f0c040" }}>Paramètres</span>
<button
onClick={onClose}
style={{ background: "none", border: "none", color: "#94a3b8", cursor: "pointer", fontSize: "16px", lineHeight: 1 }}
>
</button>
</div>
{/* Scrollable content */}
<div style={{ flex: 1, overflowY: "auto", padding: "20px", display: "flex", flexDirection: "column", gap: "28px", scrollbarWidth: "none" }}>
{/* ── Profils ── */}
<section>
<SectionTitle>Profils</SectionTitle>
<div style={{ display: "flex", flexDirection: "column", gap: "6px", marginBottom: "12px" }}>
{profiles.map(profile => {
const isActive = profile.id === activeProfileId;
return (
<div key={profile.id} style={{
display: "flex", alignItems: "center", gap: "8px",
background: isActive ? "rgba(240,192,64,0.07)" : "#161b22",
border: `1px solid ${isActive ? "rgba(240,192,64,0.35)" : "#2d3748"}`,
borderRadius: "8px", padding: "9px 12px",
}}>
<button
onClick={() => { setActiveProfile(profile.id); setProfileError(""); }}
style={{ flex: 1, background: "none", border: "none", textAlign: "left", cursor: "pointer" }}
>
<div style={{ fontSize: "13px", fontWeight: 600, color: isActive ? "#f0c040" : "#e2e8f0" }}>
{isActive && "✓ "}{profile.name}
</div>
<div style={{ fontSize: "10px", color: "#4a5568", marginTop: "2px" }}>
Créé le {new Date(profile.created_at).toLocaleDateString("fr-FR")}
</div>
</button>
{profiles.length > 1 && (
<button
onClick={() => handleDelete(profile.id)}
title="Supprimer ce profil"
style={{ background: "none", border: "none", color: "#f87171", cursor: "pointer", padding: "4px", borderRadius: "4px", fontSize: "12px" }}
>
🗑
</button>
)}
</div>
);
})}
</div>
<div style={{ display: "flex", gap: "8px" }}>
<input
value={newName}
onChange={e => { setNewName(e.target.value); setProfileError(""); }}
onKeyDown={e => e.key === "Enter" && handleCreate()}
placeholder="Nouveau profil…"
style={{
flex: 1, background: "#161b22", border: "1px solid #2d3748",
borderRadius: "6px", padding: "7px 10px", color: "#e2e8f0",
fontSize: "12px", outline: "none",
}}
onFocus={e => (e.target.style.borderColor = "#f0c040")}
onBlur={e => (e.target.style.borderColor = "#2d3748")}
/>
<button
onClick={handleCreate}
disabled={!newName.trim()}
style={{
background: "#f0c040", color: "#0d1117", border: "none",
borderRadius: "6px", padding: "7px 14px", fontWeight: 700,
fontSize: "12px", cursor: newName.trim() ? "pointer" : "default",
opacity: newName.trim() ? 1 : 0.4, flexShrink: 0,
}}
>
Créer
</button>
</div>
{profileError && <p style={{ fontSize: "11px", color: "#f87171", marginTop: "6px" }}>{profileError}</p>}
</section>
{/* ── Synchronisation ── */}
<section>
<SectionTitle>Synchronisation</SectionTitle>
<p style={{ fontSize: "12px", color: "#4a5568", marginBottom: "12px", lineHeight: 1.5 }}>
Met à jour tous les guides depuis Google Sheets.
</p>
<button
onClick={handleSync}
disabled={syncing}
style={{
width: "100%", padding: "9px", borderRadius: "7px",
background: syncing ? "rgba(74,158,255,0.08)" : "rgba(74,158,255,0.12)",
border: "1px solid rgba(74,158,255,0.3)",
color: syncing ? "#4a5568" : "#4a9eff",
fontSize: "13px", fontWeight: 600, cursor: syncing ? "default" : "pointer",
display: "flex", alignItems: "center", justifyContent: "center", gap: "8px",
transition: "all 0.15s",
}}
>
<span style={{ display: "inline-block", animation: syncing ? "spin 1s linear infinite" : "none" }}></span>
{syncing ? "Synchronisation…" : "Synchroniser maintenant"}
</button>
{syncing && syncProgress && (
<div style={{ marginTop: "12px" }}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: "11px", color: "#94a3b8", marginBottom: "6px" }}>
<span style={{ color: "#f0c040" }}>{label}</span>
<span>{current}/{total} {syncPct}%</span>
</div>
<div style={{ height: "3px", background: "#2d3748", borderRadius: "2px", overflow: "hidden" }}>
<div style={{
height: "100%", width: `${syncPct}%`,
background: "linear-gradient(90deg, #4a9eff, #f0c040)",
transition: "width 0.3s ease", borderRadius: "2px",
}} />
</div>
</div>
)}
{syncDone && !syncing && (
<div style={{ marginTop: "10px", fontSize: "12px", color: syncErrors.length === 0 ? "#4ade80" : "#f87171" }}>
{syncErrors.length === 0
? "✓ Synchronisation terminée."
: `${syncErrors.length} erreur(s) :\n${syncErrors.join("\n")}`}
</div>
)}
</section>
</div>
</div>
);
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div style={{
fontSize: "10px", fontWeight: 700, color: "#4a5568",
textTransform: "uppercase", letterSpacing: "0.1em",
marginBottom: "10px", paddingBottom: "6px",
borderBottom: "1px solid #2d3748",
}}>
{children}
</div>
);
}

View File

@ -1,9 +1,22 @@
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 = sidebarCollapsed;
const windowWidth = useWindowWidth();
const isOverlay = collapsed || windowWidth < 500;
const filtered = guides.filter(g =>
g.name.toLowerCase().includes(search.toLowerCase())
@ -11,10 +24,57 @@ export default function Sidebar() {
return (
<aside style={{
width: "220px", flexShrink: 0,
background: "#161b22", borderRight: "1px solid #2d3748",
display: "flex", flexDirection: "column", overflow: "hidden",
position: isOverlay ? "absolute" : "relative",
left: 0,
top: 0,
bottom: 0,
zIndex: 10,
width: collapsed ? "36px" : "190px",
flexShrink: 0,
background: collapsed ? "transparent" : "#161b22",
borderRight: collapsed ? "none" : "1px solid #2d3748",
display: "flex",
flexDirection: "column",
overflow: "hidden",
transition: "width 0.2s ease, background 0.2s ease",
}}>
{/* Toggle button */}
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)} title={collapsed ? "Ouvrir le menu" : "Réduire le menu"}
style={{
width: "100%",
height: "36px",
flexShrink: 0,
background: collapsed ? "rgba(22,27,34,0.9)" : "transparent",
border: collapsed ? "1px solid #2d3748" : "none",
borderLeft: "none",
borderBottom: collapsed ? "1px solid #2d3748" : "1px solid #2d3748",
borderRadius: collapsed ? "0 6px 6px 0" : "0",
color: "#4a5568",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: collapsed ? "center" : "flex-end",
padding: "0 10px",
marginTop: collapsed ? "8px" : "0",
transition: "all 0.15s",
}}
onMouseEnter={e => (e.currentTarget.style.color = "#f0c040")}
onMouseLeave={e => (e.currentTarget.style.color = "#4a5568")}
>
<span style={{
fontSize: "12px",
transform: collapsed ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.2s ease",
display: "inline-block",
}}>
</span>
</button>
{!collapsed && (
<>
{/* Search */}
<div style={{ padding: "10px 12px", borderBottom: "1px solid #2d3748" }}>
<input
value={search}
@ -23,14 +83,15 @@ export default function Sidebar() {
style={{
width: "100%", background: "#0d1117", border: "1px solid #2d3748",
borderRadius: "6px", padding: "6px 10px", color: "#e2e8f0",
fontSize: "12px", outline: "none",
fontSize: "12px", outline: "none", boxSizing: "border-box",
}}
onFocus={e => (e.target.style.borderColor = "#f0c040")}
onBlur={e => (e.target.style.borderColor = "#2d3748")}
/>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "8px 0" }}>
{/* Guide list */}
<div style={{ flex: 1, overflowY: "auto", padding: "8px 0", scrollbarWidth: "none" }}>
{filtered.length === 0 ? (
<div style={{ padding: "16px 12px", color: "#4a5568", fontSize: "12px", textAlign: "center" }}>
Aucun guide synchronisé
@ -47,7 +108,8 @@ export default function Sidebar() {
key={guide.gid}
onClick={() => openGuide(guide.gid)}
style={{
width: "100%", textAlign: "left", background: isActive ? "rgba(240,192,64,0.08)" : "transparent",
width: "100%", textAlign: "left",
background: isActive ? "rgba(240,192,64,0.08)" : "transparent",
border: "none", borderLeft: isActive ? "2px solid #f0c040" : "2px solid transparent",
padding: "8px 12px", cursor: "pointer", transition: "all 0.12s",
}}
@ -58,13 +120,18 @@ export default function Sidebar() {
if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "4px" }}>
<span style={{
fontSize: "12px", fontWeight: isActive ? 600 : 400,
color: isActive ? "#f0c040" : "#e2e8f0",
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
maxWidth: "140px",
}}>
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
wordBreak: "break-word",
lineHeight: 1.3,
flex: 1,
} as React.CSSProperties}>
{guide.name}
</span>
<span style={{
@ -89,6 +156,8 @@ export default function Sidebar() {
})
)}
</div>
</>
)}
</aside>
);
}

View File

@ -0,0 +1,53 @@
import { useState } from "react";
const COORD_RE = /\[(-?\d+),\s*(-?\d+)\]/g;
export function TextWithCoords({ text, style }: { text: string; style?: React.CSSProperties }) {
const parts: React.ReactNode[] = [];
let last = 0;
let match: RegExpExecArray | null;
COORD_RE.lastIndex = 0;
while ((match = COORD_RE.exec(text)) !== null) {
if (match.index > last) parts.push(text.slice(last, match.index));
parts.push(<CoordBadge key={match.index} x={match[1]} y={match[2]} raw={match[0]} />);
last = match.index + match[0].length;
}
if (last < text.length) parts.push(text.slice(last));
return <span style={style}>{parts}</span>;
}
function CoordBadge({ x, y, raw }: { x: string; y: string; raw: string }) {
const [copied, setCopied] = useState(false);
async function handleClick(e: React.MouseEvent) {
e.stopPropagation();
await navigator.clipboard.writeText(`/travel ${x},${y}`);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
return (
<span
onClick={handleClick}
title={`Copier /travel ${x},${y}`}
style={{
display: "inline-block",
background: copied ? "rgba(74,222,128,0.15)" : "rgba(74,158,255,0.1)",
border: `1px solid ${copied ? "rgba(74,222,128,0.4)" : "rgba(74,158,255,0.3)"}`,
borderRadius: "3px",
padding: "0 5px",
color: copied ? "#4ade80" : "#93c5fd",
cursor: "pointer",
fontSize: "0.85em",
fontFamily: "monospace",
userSelect: "none",
transition: "background 0.15s, color 0.15s, border-color 0.15s",
verticalAlign: "baseline",
}}
>
{copied ? "✓ copié" : raw}
</span>
);
}

View File

@ -1,12 +1,25 @@
import { useEffect, useState } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { useStore } from "../store";
interface Props {
onOpenProfiles: () => void;
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 TitleBar({ onOpenProfiles }: Props) {
const { alwaysOnTop, toggleAlwaysOnTop, syncing, syncGuides, view, closeGuide, activeGuideData } = useStore();
interface Props {
onOpenSettings: () => void;
}
export default function TitleBar({ onOpenSettings }: Props) {
const { view, closeGuide, activeGuideData } = useStore();
const windowWidth = useWindowWidth();
function handleDragMouseDown(e: React.MouseEvent) {
if (e.button === 0) {
@ -15,6 +28,10 @@ export default function TitleBar({ onOpenProfiles }: Props) {
}
async function handleClose() {
const viewer = await WebviewWindow.getByLabel("image-viewer");
if (viewer) {
try { await viewer.close(); } catch (_) {}
}
await getCurrentWindow().close();
}
@ -43,7 +60,7 @@ export default function TitleBar({ onOpenProfiles }: Props) {
<span style={{ pointerEvents: "none", fontSize: "13px", fontWeight: 700, color: "#f0c040", letterSpacing: "0.05em" }}>
TougliGui
</span>
{view === "guide" && activeGuideData && (
{view === "guide" && activeGuideData && windowWidth >= 400 && (
<>
<span style={{ pointerEvents: "none", color: "#4a5568", fontSize: "12px" }}></span>
<span style={{ pointerEvents: "none", fontSize: "12px", color: "#94a3b8" }}>{activeGuideData.name}</span>
@ -59,24 +76,8 @@ export default function TitleBar({ onOpenProfiles }: Props) {
</TitleButton>
)}
<TitleButton onClick={onOpenProfiles} title="Gérer les profils">
👤
</TitleButton>
<TitleButton
onClick={syncGuides}
title="Synchroniser avec Google Sheets"
disabled={syncing}
>
{syncing ? <SpinIcon /> : "↻"}
</TitleButton>
<TitleButton
onClick={toggleAlwaysOnTop}
title={alwaysOnTop ? "Désactiver fenêtre flottante" : "Activer fenêtre flottante"}
active={alwaysOnTop}
>
📌
<TitleButton onClick={onOpenSettings} title="Paramètres">
</TitleButton>
<div style={{ width: "1px", height: "16px", background: "#2d3748", margin: "0 4px" }} />
@ -134,8 +135,3 @@ function TitleButton({
);
}
function SpinIcon() {
return (
<span style={{ display: "inline-block", animation: "spin 1s linear infinite" }}></span>
);
}

View File

@ -32,10 +32,29 @@
padding: 0;
}
html, body, #root {
html, body {
height: 100%;
width: 100%;
overflow: hidden;
overscroll-behavior: none;
margin: 0;
padding: 0;
}
::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
* {
scrollbar-width: none;
}
#root {
position: fixed;
inset: 0;
overflow: hidden;
overscroll-behavior: none;
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
}
@ -45,20 +64,25 @@ body {
user-select: none;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
/* Scrollbar explicite sur les zones qui en ont besoin */
.with-scrollbar {
scrollbar-width: thin !important;
scrollbar-color: var(--color-border-bright) var(--color-bg-deep) !important;
}
::-webkit-scrollbar-track {
background: var(--color-bg-deep);
.with-scrollbar::-webkit-scrollbar {
display: block !important;
width: 4px !important;
height: 4px !important;
}
::-webkit-scrollbar-thumb {
background: var(--color-border-bright);
border-radius: 3px;
.with-scrollbar::-webkit-scrollbar-track {
background: var(--color-bg-deep) !important;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-gold-dim);
.with-scrollbar::-webkit-scrollbar-thumb {
background: var(--color-border-bright) !important;
border-radius: 2px !important;
}
.with-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--color-gold-dim) !important;
}
/* Checkbox custom */

View File

@ -1,9 +1,27 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import ImageViewerWindow from "./components/ImageViewerWindow";
// Block all document-level scrolling
document.addEventListener("wheel", (e) => {
let el = e.target as HTMLElement | null;
while (el && el !== document.documentElement) {
const { overflowY } = window.getComputedStyle(el);
if (overflowY === "scroll" || overflowY === "auto") return;
el = el.parentElement;
}
e.preventDefault();
}, { passive: false });
document.addEventListener("scroll", () => {
window.scrollTo(0, 0);
}, { passive: true });
const isViewer = new URLSearchParams(window.location.search).get("viewer") === "1";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
{isViewer ? <ImageViewerWindow /> : <App />}
</React.StrictMode>,
);

View File

@ -9,11 +9,16 @@ interface AppState {
activeGuideGid: string | null;
activeGuideData: GuideData | null;
completedQuests: Set<string>;
alwaysOnTop: boolean;
syncing: boolean;
syncProgress: { current: number; total: number; label: string } | null;
view: "home" | "guide";
sidebarCollapsed: boolean;
resourcesPanelCollapsed: boolean;
resourceInventory: Record<string, number>;
setResourcesPanelCollapsed: (v: boolean) => void;
loadResourceInventory: () => Promise<void>;
setResourceQuantity: (name: string, qty: number) => Promise<void>;
loadProfiles: () => Promise<void>;
setActiveProfile: (id: string) => Promise<void>;
createProfile: (name: string) => Promise<void>;
@ -21,13 +26,13 @@ interface AppState {
loadGuides: () => Promise<void>;
openGuide: (gid: string) => Promise<void>;
setSidebarCollapsed: (collapsed: boolean) => void;
closeGuide: () => void;
toggleQuest: (questName: string) => Promise<void>;
syncGuides: () => Promise<SyncResult>;
syncSingleGuide: (gid: string, name: string) => Promise<void>;
toggleAlwaysOnTop: () => Promise<void>;
}
export const useStore = create<AppState>((set, get) => ({
@ -37,10 +42,30 @@ export const useStore = create<AppState>((set, get) => ({
activeGuideGid: null,
activeGuideData: null,
completedQuests: new Set(),
alwaysOnTop: true,
syncing: false,
syncProgress: null,
view: "home",
sidebarCollapsed: false,
resourcesPanelCollapsed: false,
resourceInventory: {},
setResourcesPanelCollapsed: (v) => set({ resourcesPanelCollapsed: v }),
loadResourceInventory: async () => {
const { activeProfileId } = get();
if (!activeProfileId) return;
const rows = await invoke<[string, number][]>("get_resource_inventory", { profileId: activeProfileId });
const inventory: Record<string, number> = {};
for (const [name, qty] of rows) inventory[name] = qty;
set({ resourceInventory: inventory });
},
setResourceQuantity: async (name, qty) => {
const { activeProfileId } = get();
if (!activeProfileId) return;
set(state => ({ resourceInventory: { ...state.resourceInventory, [name]: qty } }));
await invoke("set_resource_quantity", { profileId: activeProfileId, resourceName: name, quantity: qty });
},
loadProfiles: async () => {
const profiles = await invoke<Profile[]>("get_profiles");
@ -50,6 +75,7 @@ export const useStore = create<AppState>((set, get) => ({
if (activeId) {
const completed = await invoke<string[]>("get_completed_quests", { profileId: activeId });
set({ completedQuests: new Set(completed) });
await get().loadResourceInventory();
}
},
@ -57,7 +83,7 @@ export const useStore = create<AppState>((set, get) => ({
await invoke("set_setting", { key: "active_profile", value: id });
const completed = await invoke<string[]>("get_completed_quests", { profileId: id });
set({ activeProfileId: id, completedQuests: new Set(completed) });
await get().loadGuides();
await Promise.all([get().loadGuides(), get().loadResourceInventory()]);
},
createProfile: async (name) => {
@ -84,10 +110,14 @@ export const useStore = create<AppState>((set, get) => ({
openGuide: async (gid) => {
const data = await invoke<GuideData>("get_guide", { gid });
await invoke("set_setting", { key: "active_guide", value: gid });
set({ activeGuideGid: gid, activeGuideData: data, view: "guide" });
},
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
closeGuide: () => {
invoke("set_setting", { key: "active_guide", value: "" });
set({ activeGuideGid: null, activeGuideData: null, view: "home" });
},
@ -133,10 +163,4 @@ export const useStore = create<AppState>((set, get) => ({
syncSingleGuide: async (gid, name) => {
await invoke("sync_single_guide", { gid, name });
},
toggleAlwaysOnTop: async () => {
const next = !get().alwaysOnTop;
await invoke("set_always_on_top", { value: next });
set({ alwaysOnTop: next });
},
}));

View File

@ -68,3 +68,10 @@ export interface SyncResult {
synced: number;
errors: string[];
}
export interface QuestStep {
index: number;
text: string;
images: string[];
launch_position: string | null;
}