13 Commits

30 changed files with 2173 additions and 1039 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

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).

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", "@tauri-apps/cli": "^2",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^6.0.1",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"tailwindcss": "^4.2.4", "tailwindcss": "^4.2.4",
"typescript": "~5.8.3", "typescript": "~5.8.3",

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"

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

118
src-tauri/Cargo.lock generated
View File

@ -26,6 +26,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"getrandom 0.3.4",
"once_cell", "once_cell",
"version_check", "version_check",
"zerocopy", "zerocopy",
@ -766,6 +767,19 @@ dependencies = [
"syn 1.0.109", "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]] [[package]]
name = "cssparser" name = "cssparser"
version = "0.36.0" version = "0.36.0"
@ -1081,6 +1095,12 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ego-tree"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12a0bb14ac04a9fcf170d0bbbef949b44cc492f4452bd20c095636956f653642"
[[package]] [[package]]
name = "either" name = "either"
version = "1.15.0" version = "1.15.0"
@ -1568,6 +1588,15 @@ dependencies = [
"version_check", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.1.16" version = "0.1.16"
@ -1880,6 +1909,20 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "html5ever" name = "html5ever"
version = "0.29.1" version = "0.29.1"
@ -2494,6 +2537,20 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" 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]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.14.1" version = "0.14.1"
@ -3092,6 +3149,16 @@ dependencies = [
"phf_shared 0.8.0", "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]] [[package]]
name = "phf_codegen" name = "phf_codegen"
version = "0.11.3" version = "0.11.3"
@ -4093,6 +4160,22 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 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]] [[package]]
name = "seahash" name = "seahash"
version = "4.1.0" version = "4.1.0"
@ -4140,6 +4223,25 @@ dependencies = [
"smallvec", "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]] [[package]]
name = "selectors" name = "selectors"
version = "0.36.1" version = "0.36.1"
@ -4339,6 +4441,15 @@ dependencies = [
"stable_deref_trait", "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]] [[package]]
name = "servo_arc" name = "servo_arc"
version = "0.4.3" version = "0.4.3"
@ -5592,6 +5703,7 @@ dependencies = [
"gtk", "gtk",
"reqwest 0.12.28", "reqwest 0.12.28",
"rusqlite", "rusqlite",
"scraper",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@ -5806,6 +5918,12 @@ version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]] [[package]]
name = "unicode-xid" name = "unicode-xid"
version = "0.2.6" version = "0.2.6"

View File

@ -26,6 +26,7 @@ tokio = { version = "1", features = ["full"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
dirs-next = "2" dirs-next = "2"
scraper = "0.20"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
webkit2gtk = { version = "2.0", features = ["v2_38"] } webkit2gtk = { version = "2.0", features = ["v2_38"] }

View File

@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for the main window", "description": "Capability for the main window",
"windows": ["main"], "windows": ["main", "image-viewer"],
"permissions": [ "permissions": [
"core:default", "core:default",
"opener:default", "opener:default",
@ -21,6 +21,7 @@
"core:window:allow-set-cursor-visible", "core:window:allow-set-cursor-visible",
"core:window:allow-is-maximized", "core:window:allow-is-maximized",
"core:window:allow-is-minimized", "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 serde::{Deserialize, Serialize};
use std::sync::Mutex; use std::sync::Mutex;
use rusqlite::Connection; use rusqlite::Connection;
@ -173,6 +174,18 @@ pub fn has_guides(state: State<DbState>) -> Result<bool, String> {
Ok(!guides.is_empty()) 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] #[tauri::command]
pub fn get_setting(state: State<DbState>, key: String) -> Result<Option<String>, String> { pub fn get_setting(state: State<DbState>, key: String) -> Result<Option<String>, String> {
let conn = state.0.lock().map_err(|e| e.to_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> { fn collect_quest_names(data: &parser::GuideData) -> Vec<String> {
let mut names = Vec::new(); let mut names = Vec::new();
for section in &data.sections { for section in &data.sections {

View File

@ -60,6 +60,22 @@ pub fn migrate(conn: &Connection) -> Result<()> {
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL 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(()) Ok(())
} }
@ -145,6 +161,62 @@ pub fn get_guides(conn: &Connection) -> Result<Vec<GuideRow>> {
rows.collect() 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> { pub fn get_setting(conn: &Connection, key: &str) -> Option<String> {
conn.query_row( conn.query_row(
"SELECT value FROM settings WHERE key = ?1", "SELECT value FROM settings WHERE key = ?1",

View File

@ -21,6 +21,12 @@ pub fn run() {
use webkit2gtk::WebViewExt; use webkit2gtk::WebViewExt;
let window = app.get_webview_window("main").expect("no main window"); 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| { window.with_webview(|wv| {
let webkit_view = wv.inner(); let webkit_view = wv.inner();
if let Some(parent) = webkit_view.parent() { if let Some(parent) = webkit_view.parent() {
@ -48,6 +54,12 @@ pub fn run() {
commands::get_setting, commands::get_setting,
commands::set_setting, commands::set_setting,
commands::set_always_on_top, 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!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@ -15,8 +15,8 @@
"title": "TougliGui", "title": "TougliGui",
"width": 1100, "width": 1100,
"height": 720, "height": 720,
"minWidth": 800, "minWidth": 300,
"minHeight": 500, "minHeight": 400,
"decorations": false, "decorations": false,
"transparent": false, "transparent": false,
"backgroundColor": "#0d1117", "backgroundColor": "#0d1117",

View File

@ -1,29 +1,86 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core"; 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 { useStore } from "./store";
import TitleBar from "./components/TitleBar"; import TitleBar from "./components/TitleBar";
import Sidebar from "./components/Sidebar"; import ResizeHandles from "./components/ResizeHandles";
import HomeView from "./components/HomeView"; import HomeView from "./components/HomeView";
import GuideView from "./components/GuideView"; import GuideView from "./components/GuideView";
import ProfileModal from "./components/ProfileModal"; import SettingsPanel from "./components/SettingsPanel";
import SyncOverlay from "./components/SyncOverlay"; import SyncOverlay from "./components/SyncOverlay";
export default function App() { export default function App() {
const { loadProfiles, loadGuides, view, syncing, syncGuides } = useStore(); const { loadProfiles, loadGuides, openGuide, setResourcesPanelCollapsed, view, syncing, syncGuides } = useStore();
const [showProfileModal, setShowProfileModal] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [needsSync, setNeedsSync] = useState(false); const [needsSync, setNeedsSync] = useState(false);
useEffect(() => { useEffect(() => {
async function init() { 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(); await loadProfiles();
const has = await invoke<boolean>("has_guides"); const has = await invoke<boolean>("has_guides");
if (!has) { if (!has) {
setNeedsSync(true); setNeedsSync(true);
} else { } else {
await loadGuides(); 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(); 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() { async function handleInitialSync() {
@ -33,16 +90,16 @@ export default function App() {
return ( return (
<div className="app-shell"> <div className="app-shell">
<TitleBar onOpenProfiles={() => setShowProfileModal(true)} /> <ResizeHandles />
<TitleBar onOpenSettings={() => setShowSettings(s => !s)} />
<div className="app-body"> <div className="app-body">
<Sidebar />
<main className="app-main"> <main className="app-main">
{view === "home" ? <HomeView /> : <GuideView />} {view === "home" ? <HomeView /> : <GuideView />}
</main> </main>
</div> </div>
{showProfileModal && <ProfileModal onClose={() => setShowProfileModal(false)} />} {showSettings && <SettingsPanel onClose={() => setShowSettings(false)} />}
{syncing && <SyncOverlay />} {syncing && !showSettings && <SyncOverlay />}
{needsSync && ( {needsSync && (
<div style={{ <div style={{
position: "fixed", inset: 0, background: "rgba(0,0,0,0.85)", position: "fixed", inset: 0, background: "rgba(0,0,0,0.85)",

View File

@ -1,63 +1,111 @@
import { openUrl } from "@tauri-apps/plugin-opener"; import { useEffect, useState } from "react";
import { useStore } from "../store"; 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() { 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; 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 allQuests = collectAllQuests(sections);
const completedCount = allQuests.filter(q => completedQuests.has(q)).length; const completedCount = allQuests.filter(q => completedQuests.has(q)).length;
const pct = allQuests.length > 0 ? Math.round((completedCount / allQuests.length) * 100) : 0; const pct = allQuests.length > 0 ? Math.round((completedCount / allQuests.length) * 100) : 0;
const isDone = pct === 100;
return ( return (
<div style={{ flex: 1, display: "flex", overflow: "hidden", minHeight: 0 }}> <div style={{ flex: 1, display: "flex", overflow: "hidden", minHeight: 0, position: "relative" }}>
{/* Main quest list */} <div className="with-scrollbar" style={{ flex: 1, overflowY: "auto", padding: "20px 24px" }}>
<div style={{ flex: 1, overflowY: "auto", padding: "20px 24px" }}>
{/* Guide header */} {/* Header */}
<div style={{ marginBottom: "20px" }}> <div style={{ marginBottom: "20px" }}>
<h1 style={{ fontSize: "20px", fontWeight: 700, color: "#f0c040", marginBottom: "4px" }}> <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: "8px", flexWrap: "wrap" }}>
{name} <div style={{ minWidth: 0 }}>
</h1> <h1 style={{ fontSize: "18px", fontWeight: 700, color: "#f0c040", marginBottom: "2px", wordBreak: "break-word" }}>{name}</h1>
<div style={{ display: "flex", gap: "16px", alignItems: "center", marginBottom: "10px" }}>
{recommended_level && ( {recommended_level && (
<span style={{ <div style={{ fontSize: "12px", color: "#94a3b8" }}>
fontSize: "11px", background: "rgba(74,158,255,0.15)", color: "#4a9eff", Niv. recommandé : <span style={{ color: "#e2e8f0", fontWeight: 600 }}>{recommended_level}</span>
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> </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={{ <div style={{
height: "100%", width: `${pct}%`, 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", borderRadius: "2px", transition: "width 0.3s ease",
}} /> }} />
</div> </div>
{effect && ( {effect && (
<div style={{ <div style={{
marginTop: "10px", background: "rgba(240,192,64,0.05)", marginTop: "12px", padding: "10px 14px",
border: "1px solid rgba(240,192,64,0.2)", borderRadius: "6px", background: "rgba(240,192,64,0.04)", borderRadius: "6px",
padding: "8px 12px", fontSize: "11px", color: "#94a3b8", lineHeight: 1.5, 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} {effect}
</div> </div>
)} )}
</div> </div>
{/* Légende */}
{combat_legend.length > 0 && (
<Legend legend={combat_legend} />
)}
{/* Sections */} {/* Sections */}
{sections.map((section, si) => ( {sections.map((section, si) => (
<div key={si} style={{ marginBottom: "20px" }}> <div key={si} style={{ marginBottom: "24px" }}>
<h2 style={{ <h2 style={{
fontSize: "11px", fontWeight: 600, color: "#4a5568", fontSize: "11px", fontWeight: 600, color: "#4a5568",
textTransform: "uppercase", letterSpacing: "0.1em", textTransform: "uppercase", letterSpacing: "0.1em",
@ -66,7 +114,7 @@ export default function GuideView() {
{section.name} {section.name}
</h2> </h2>
{section.items.map((item, ii) => ( {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> </div>
))} ))}
@ -75,35 +123,144 @@ export default function GuideView() {
{/* Resources panel */} {/* Resources panel */}
{resources.length > 0 && ( {resources.length > 0 && (
<div style={{ <div style={{
width: "200px", flexShrink: 0, background: "#161b22", position: resourcesIsOverlay ? "absolute" : "relative",
borderLeft: "1px solid #2d3748", overflowY: "auto", padding: "16px 14px", 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={{ {/* Toggle */}
fontSize: "11px", fontWeight: 600, color: "#4a5568", <button
textTransform: "uppercase", letterSpacing: "0.1em", marginBottom: "10px", 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 Ressources
</h3> </span>
{resources.map((r, i) => ( )}
</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={{ <div key={i} style={{
display: "flex", justifyContent: "space-between", alignItems: "center", padding: "6px 0", borderBottom: "1px solid #1f2937", fontSize: "12px",
padding: "4px 0", borderBottom: "1px solid #1f2937",
fontSize: "11px",
}}> }}>
<span style={{ color: "#94a3b8", flex: 1, marginRight: "8px" }}>{r.name}</span> <span style={{
<span style={{ color: "#f0c040", fontWeight: 700, flexShrink: 0 }}>×{r.quantity}</span> 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> </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; item: SectionItem;
completedQuests: Set<string>; completedQuests: Set<string>;
onToggle: (name: string) => void; onToggle: (name: string) => void;
onSelect: (quest: { name: string; url: string | null }) => void;
}) { }) {
if (item.type === "Instruction") { if (item.type === "Instruction") {
if (item.text.startsWith("__ZONE__:")) { if (item.text.startsWith("__ZONE__:")) {
@ -121,11 +278,16 @@ function SectionItemView({ item, completedQuests, onToggle }: {
} }
return ( return (
<div style={{ <div style={{
background: "rgba(74,158,255,0.05)", border: "1px solid rgba(74,158,255,0.15)", marginBottom: "6px", padding: "9px 14px",
borderRadius: "6px", padding: "8px 12px", marginBottom: "4px", background: "rgba(74,158,255,0.04)",
fontSize: "11px", color: "#94a3b8", lineHeight: 1.5, 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> </div>
); );
} }
@ -137,98 +299,74 @@ function SectionItemView({ item, completedQuests, onToggle }: {
borderRadius: "6px", padding: "8px 10px", marginBottom: "6px", borderRadius: "6px", padding: "8px 10px", marginBottom: "6px",
}}> }}>
{item.note && ( {item.note && (
<div style={{ <div style={{ fontSize: "11px", color: "#4a9eff", marginBottom: "6px", fontStyle: "italic" }}>
fontSize: "10px", color: "#4a9eff", marginBottom: "6px",
fontStyle: "italic",
}}>
🔗 {item.note} 🔗 {item.note}
</div> </div>
)} )}
{item.quests.map((q, i) => ( {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> </div>
); );
} }
if (item.type === "Quest") { 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; return null;
} }
function QuestRow({ quest, completed, onToggle, indent }: { function QuestRow({ quest, completed, onToggle, onSelect, indent }: {
quest: QuestItem; quest: QuestItem;
completed: boolean; completed: boolean;
onToggle: (name: string) => void; onToggle: (name: string) => void;
onSelect: (quest: { name: string; url: string | null }) => void;
indent?: boolean; indent?: boolean;
}) { }) {
return ( return (
<div <div
onClick={() => onToggle(quest.name)}
style={{ style={{
display: "flex", alignItems: "flex-start", gap: "8px", display: "flex", alignItems: "flex-start", gap: "8px",
padding: indent ? "4px 0" : "5px 6px", padding: indent ? "3px 0" : "4px 6px",
borderRadius: "5px", cursor: "pointer", borderRadius: "5px",
marginBottom: indent ? "2px" : "3px", marginBottom: indent ? "1px" : "2px",
opacity: completed ? 0.6 : 1, opacity: completed ? 0.5 : 1,
transition: "all 0.12s", transition: "all 0.12s",
}} }}
onMouseEnter={e => { onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.04)"; }}
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.04)"; onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
}}
onMouseLeave={e => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
> >
<input <input
type="checkbox" type="checkbox"
checked={completed} checked={completed}
onChange={() => onToggle(quest.name)} onChange={() => onToggle(quest.name)}
onClick={e => e.stopPropagation()} style={{ marginTop: "2px", flexShrink: 0, cursor: "pointer" }}
style={{ marginTop: "2px", flexShrink: 0 }}
/> />
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: "5px", flexWrap: "wrap" }}> <div style={{ display: "flex", alignItems: "baseline", flexWrap: "wrap", gap: "2px 8px" }}>
<span style={{ <span
fontSize: "12px", color: completed ? "#4a5568" : "#e2e8f0", onClick={() => onSelect({ name: quest.name, url: quest.url })}
textDecoration: completed ? "line-through" : "none", style={{
lineHeight: 1.4, 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} {quest.name}
</span> </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) => ( {quest.combat_indicators.map((ci, i) => (
<span key={i} style={{ <span key={i} style={{ fontSize: "11px", color: "#94a3b8", whiteSpace: "nowrap", flexShrink: 0 }}>
fontSize: "9px", padding: "1px 5px", {combatIcon(ci.combat_type)} x{ci.count}
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> </span>
))} ))}
</div> </div>
)}
{quest.note && ( {quest.note && (
<div style={{ fontSize: "10px", color: "#94a3b8", marginTop: "2px", fontStyle: "italic" }}> <div style={{ fontSize: "11px", color: "#4a5568", marginTop: "2px", fontStyle: "italic" }}>
{quest.note} <TextWithCoords text={quest.note} />
</div> </div>
)} )}
</div> </div>
@ -236,13 +374,12 @@ function QuestRow({ quest, completed, onToggle, indent }: {
); );
} }
function collectAllQuests(sections: import("../types").Section[] | undefined): string[] { function collectAllQuests(sections: import("../types").Section[]): string[] {
if (!sections) return [];
const names: string[] = []; const names: string[] = [];
for (const section of sections) { for (const section of sections) {
for (const item of section.items) { for (const item of section.items) {
if (item.type === "Quest") names.push(item.name); 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; return names;

View File

@ -4,19 +4,17 @@ export default function HomeView() {
const { guides, openGuide, profiles, activeProfileId } = useStore(); const { guides, openGuide, profiles, activeProfileId } = useStore();
const activeProfile = profiles.find(p => p.id === activeProfileId); const activeProfile = profiles.find(p => p.id === activeProfileId);
const totalQuests = guides.reduce((s, g) => s + g.total_quests, 0); const totalQuests = guides.reduce((s, g) => s + g.total_quests, 0);
const totalCompleted = guides.reduce((s, g) => s + g.completed_quests, 0); const totalCompleted = guides.reduce((s, g) => s + g.completed_quests, 0);
const globalPct = totalQuests > 0 ? Math.round((totalCompleted / totalQuests) * 100) : 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 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); const inProgressGuides = guides.filter(g => g.completed_quests > 0 && g.completed_quests < g.total_quests);
return ( return (
<div style={{ flex: 1, overflowY: "auto", padding: "20px 24px", minHeight: 0 }}> <div className="with-scrollbar" style={{ flex: 1, overflowY: "auto", padding: "20px 24px", minHeight: 0 }}>
{/* Header */} {/* Header */}
<div style={{ marginBottom: "24px" }}> <div style={{ marginBottom: "20px" }}>
<h1 style={{ fontSize: "18px", fontWeight: 700, color: "#f0c040", marginBottom: "4px" }}> <h1 style={{ fontSize: "20px", fontWeight: 700, color: "#f0c040", marginBottom: "2px" }}>
Tougli Guide Dofus Tougli Guide Dofus
</h1> </h1>
{activeProfile && ( {activeProfile && (
@ -32,7 +30,7 @@ export default function HomeView() {
background: "#161b22", border: "1px solid #2d3748", borderRadius: "10px", background: "#161b22", border: "1px solid #2d3748", borderRadius: "10px",
padding: "16px 20px", marginBottom: "24px", 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", color: "#94a3b8" }}>Progression globale</span>
<span style={{ fontSize: "13px", fontWeight: 700, color: "#f0c040" }}> <span style={{ fontSize: "13px", fontWeight: 700, color: "#f0c040" }}>
{totalCompleted} / {totalQuests} quêtes ({globalPct}%) {totalCompleted} / {totalQuests} quêtes ({globalPct}%)
@ -45,7 +43,7 @@ export default function HomeView() {
borderRadius: "3px", transition: "width 0.4s ease", borderRadius: "3px", transition: "width 0.4s ease",
}} /> }} />
</div> </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="Complétés" value={completedGuides.length} color="#4ade80" />
<Stat label="En cours" value={inProgressGuides.length} color="#f0c040" /> <Stat label="En cours" value={inProgressGuides.length} color="#f0c040" />
<Stat label="Total" value={guides.length} color="#94a3b8" /> <Stat label="Total" value={guides.length} color="#94a3b8" />
@ -53,12 +51,12 @@ export default function HomeView() {
</div> </div>
)} )}
{/* In progress first */} {/* En cours */}
{inProgressGuides.length > 0 && ( {inProgressGuides.length > 0 && (
<Section title="En cours" guides={inProgressGuides} onOpen={openGuide} /> <Section title="En cours" guides={inProgressGuides} onOpen={openGuide} />
)} )}
{/* All guides grid */} {/* Tous les guides */}
<Section title="Tous les guides" guides={guides} onOpen={openGuide} /> <Section title="Tous les guides" guides={guides} onOpen={openGuide} />
</div> </div>
); );
@ -66,9 +64,9 @@ export default function HomeView() {
function Stat({ label, value, color }: { label: string; value: number; color: string }) { function Stat({ label, value, color }: { label: string; value: number; color: string }) {
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}> <div>
<span style={{ fontSize: "16px", fontWeight: 700, color }}>{value}</span> <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> </div>
); );
} }
@ -80,13 +78,15 @@ function Section({ title, guides, onOpen }: {
}) { }) {
return ( return (
<div style={{ marginBottom: "24px" }}> <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} {title}
</h2> </h2>
<div style={{ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(175px, 1fr))", gap: "8px" }}>
display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: "8px", {guides.map(g => <GuideCard key={g.gid} guide={g} onOpen={onOpen} />)}
}}>
{guides.map(guide => <GuideCard key={guide.gid} guide={guide} onOpen={onOpen} />)}
</div> </div>
</div> </div>
); );
@ -96,53 +96,64 @@ function GuideCard({ guide, onOpen }: {
guide: import("../types").GuideListItem; guide: import("../types").GuideListItem;
onOpen: (gid: string) => void; onOpen: (gid: string) => void;
}) { }) {
const pct = guide.total_quests > 0 const pct = guide.total_quests > 0 ? Math.round((guide.completed_quests / guide.total_quests) * 100) : 0;
? Math.round((guide.completed_quests / guide.total_quests) * 100)
: 0;
const isDone = pct === 100 && guide.total_quests > 0; const isDone = pct === 100 && guide.total_quests > 0;
const inProgress = guide.completed_quests > 0 && !isDone;
const accentColor = isDone ? "#4ade80" : inProgress ? "#f0c040" : "#4a9eff";
return ( return (
<button <button
onClick={() => onOpen(guide.gid)} onClick={() => onOpen(guide.gid)}
style={{ style={{
background: isDone ? "rgba(74,222,128,0.05)" : "#161b22", background: "#161b22", border: `1px solid ${isDone ? "rgba(74,222,128,0.25)" : "#2d3748"}`,
border: `1px solid ${isDone ? "rgba(74,222,128,0.3)" : "#2d3748"}`,
borderRadius: "8px", padding: "12px 14px", cursor: "pointer", 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 => { onMouseEnter={e => {
(e.currentTarget as HTMLElement).style.borderColor = isDone ? "rgba(74,222,128,0.6)" : "#f0c040"; (e.currentTarget as HTMLElement).style.borderColor = accentColor;
(e.currentTarget as HTMLElement).style.background = isDone ? "rgba(74,222,128,0.08)" : "#1a2233"; (e.currentTarget as HTMLElement).style.background = "#1a2233";
}} }}
onMouseLeave={e => { onMouseLeave={e => {
(e.currentTarget as HTMLElement).style.borderColor = isDone ? "rgba(74,222,128,0.3)" : "#2d3748"; (e.currentTarget as HTMLElement).style.borderColor = isDone ? "rgba(74,222,128,0.25)" : "#2d3748";
(e.currentTarget as HTMLElement).style.background = isDone ? "rgba(74,222,128,0.05)" : "#161b22"; (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" }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "8px" }}>
<span style={{ <span style={{
fontSize: "12px", fontWeight: 600, color: isDone ? "#4ade80" : "#e2e8f0", fontSize: "12px", fontWeight: 600, lineHeight: 1.3,
lineHeight: 1.3, color: isDone ? "#4ade80" : "#e2e8f0",
}}> }}>
{guide.name} {guide.name}
</span> </span>
{isDone && <span style={{ fontSize: "14px" }}></span>} {isDone && <span style={{ fontSize: "12px", flexShrink: 0 }}></span>}
</div> </div>
<div style={{ height: "3px", background: "#2d3748", borderRadius: "2px", overflow: "hidden", marginBottom: "6px" }}> <div style={{ height: "3px", background: "#2d3748", borderRadius: "2px", overflow: "hidden", marginBottom: "6px" }}>
<div style={{ <div style={{
height: "100%", width: `${pct}%`, height: "100%", width: `${pct}%`,
background: isDone ? "#4ade80" : pct > 60 ? "#f0c040" : "#4a9eff", background: accentColor,
borderRadius: "2px", borderRadius: "2px", transition: "width 0.3s ease",
}} /> }} />
</div> </div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "10px", color: "#4a5568" }}> <span style={{ fontSize: "10px", color: "#4a5568" }}>
{guide.completed_quests}/{guide.total_quests} quêtes {guide.completed_quests}/{guide.total_quests} quêtes
</span> </span>
<span style={{ fontSize: "10px", fontWeight: 700, color: isDone ? "#4ade80" : "#94a3b8" }}> <span style={{ fontSize: "10px", fontWeight: 700, color: accentColor }}>
{pct}% {pct}%
</span> </span>
</div> </div>
</div>
</button> </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"; 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() { export default function Sidebar() {
const { guides, openGuide, activeGuideGid, view } = useStore(); const { guides, openGuide, activeGuideGid, view, sidebarCollapsed, setSidebarCollapsed } = useStore();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const collapsed = sidebarCollapsed;
const windowWidth = useWindowWidth();
const isOverlay = collapsed || windowWidth < 500;
const filtered = guides.filter(g => const filtered = guides.filter(g =>
g.name.toLowerCase().includes(search.toLowerCase()) g.name.toLowerCase().includes(search.toLowerCase())
@ -11,10 +24,57 @@ export default function Sidebar() {
return ( return (
<aside style={{ <aside style={{
width: "220px", flexShrink: 0, position: isOverlay ? "absolute" : "relative",
background: "#161b22", borderRight: "1px solid #2d3748", left: 0,
display: "flex", flexDirection: "column", overflow: "hidden", 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" }}> <div style={{ padding: "10px 12px", borderBottom: "1px solid #2d3748" }}>
<input <input
value={search} value={search}
@ -23,13 +83,14 @@ export default function Sidebar() {
style={{ style={{
width: "100%", background: "#0d1117", border: "1px solid #2d3748", width: "100%", background: "#0d1117", border: "1px solid #2d3748",
borderRadius: "6px", padding: "6px 10px", color: "#e2e8f0", 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")} onFocus={e => (e.target.style.borderColor = "#f0c040")}
onBlur={e => (e.target.style.borderColor = "#2d3748")} onBlur={e => (e.target.style.borderColor = "#2d3748")}
/> />
</div> </div>
{/* Guide list */}
<div style={{ flex: 1, overflowY: "auto", padding: "8px 0", scrollbarWidth: "none" }}> <div style={{ flex: 1, overflowY: "auto", padding: "8px 0", scrollbarWidth: "none" }}>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div style={{ padding: "16px 12px", color: "#4a5568", fontSize: "12px", textAlign: "center" }}> <div style={{ padding: "16px 12px", color: "#4a5568", fontSize: "12px", textAlign: "center" }}>
@ -47,7 +108,8 @@ export default function Sidebar() {
key={guide.gid} key={guide.gid}
onClick={() => openGuide(guide.gid)} onClick={() => openGuide(guide.gid)}
style={{ 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", border: "none", borderLeft: isActive ? "2px solid #f0c040" : "2px solid transparent",
padding: "8px 12px", cursor: "pointer", transition: "all 0.12s", 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"; 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={{ <span style={{
fontSize: "12px", fontWeight: isActive ? 600 : 400, fontSize: "12px", fontWeight: isActive ? 600 : 400,
color: isActive ? "#f0c040" : "#e2e8f0", color: isActive ? "#f0c040" : "#e2e8f0",
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", display: "-webkit-box",
maxWidth: "140px", WebkitLineClamp: 2,
}}> WebkitBoxOrient: "vertical",
overflow: "hidden",
wordBreak: "break-word",
lineHeight: 1.3,
flex: 1,
} as React.CSSProperties}>
{guide.name} {guide.name}
</span> </span>
<span style={{ <span style={{
@ -89,6 +156,8 @@ export default function Sidebar() {
}) })
)} )}
</div> </div>
</>
)}
</aside> </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,20 +1,37 @@
import { useEffect, useState } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { useStore } from "../store"; import { useStore } from "../store";
interface Props { function useWindowWidth() {
onOpenProfiles: () => void; 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) { interface Props {
const { alwaysOnTop, toggleAlwaysOnTop, syncing, syncGuides, view, closeGuide, activeGuideData } = useStore(); onOpenSettings: () => void;
}
export default function TitleBar({ onOpenSettings }: Props) {
const { view, closeGuide, activeGuideData } = useStore();
const windowWidth = useWindowWidth();
function handleDragMouseDown(e: React.MouseEvent) { function handleDragMouseDown(e: React.MouseEvent) {
if (e.button === 0 && !alwaysOnTop) { if (e.button === 0) {
getCurrentWindow().startDragging(); getCurrentWindow().startDragging();
} }
} }
async function handleClose() { async function handleClose() {
const viewer = await WebviewWindow.getByLabel("image-viewer");
if (viewer) {
try { await viewer.close(); } catch (_) {}
}
await getCurrentWindow().close(); await getCurrentWindow().close();
} }
@ -36,14 +53,14 @@ export default function TitleBar({ onOpenProfiles }: Props) {
onMouseDown={handleDragMouseDown} onMouseDown={handleDragMouseDown}
style={{ style={{
display: "flex", alignItems: "center", gap: "8px", flex: 1, display: "flex", alignItems: "center", gap: "8px", flex: 1,
cursor: alwaysOnTop ? "default" : "grab", userSelect: "none", cursor: "grab", userSelect: "none",
}} }}
> >
<img src="/logo_tougli.png" style={{ pointerEvents: "none", width: "24px", height: "24px", objectFit: "contain" }} /> <img src="/logo_tougli.png" style={{ pointerEvents: "none", width: "24px", height: "24px", objectFit: "contain" }} />
<span style={{ pointerEvents: "none", fontSize: "13px", fontWeight: 700, color: "#f0c040", letterSpacing: "0.05em" }}> <span style={{ pointerEvents: "none", fontSize: "13px", fontWeight: 700, color: "#f0c040", letterSpacing: "0.05em" }}>
TougliGui TougliGui
</span> </span>
{view === "guide" && activeGuideData && ( {view === "guide" && activeGuideData && windowWidth >= 400 && (
<> <>
<span style={{ pointerEvents: "none", color: "#4a5568", fontSize: "12px" }}></span> <span style={{ pointerEvents: "none", color: "#4a5568", fontSize: "12px" }}></span>
<span style={{ pointerEvents: "none", fontSize: "12px", color: "#94a3b8" }}>{activeGuideData.name}</span> <span style={{ pointerEvents: "none", fontSize: "12px", color: "#94a3b8" }}>{activeGuideData.name}</span>
@ -59,24 +76,8 @@ export default function TitleBar({ onOpenProfiles }: Props) {
</TitleButton> </TitleButton>
)} )}
<TitleButton onClick={onOpenProfiles} title="Gérer les profils"> <TitleButton onClick={onOpenSettings} title="Paramètres">
👤
</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> </TitleButton>
<div style={{ width: "1px", height: "16px", background: "#2d3748", margin: "0 4px" }} /> <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

@ -40,14 +40,13 @@ html, body {
padding: 0; padding: 0;
} }
html::-webkit-scrollbar, ::-webkit-scrollbar {
body::-webkit-scrollbar {
display: none; display: none;
width: 0; width: 0;
height: 0; height: 0;
} }
html, body { * {
scrollbar-width: none; scrollbar-width: none;
} }
@ -65,20 +64,25 @@ body {
user-select: none; user-select: none;
} }
/* Custom scrollbar */ /* Scrollbar explicite sur les zones qui en ont besoin */
::-webkit-scrollbar { .with-scrollbar {
width: 6px; scrollbar-width: thin !important;
height: 6px; scrollbar-color: var(--color-border-bright) var(--color-bg-deep) !important;
} }
::-webkit-scrollbar-track { .with-scrollbar::-webkit-scrollbar {
background: var(--color-bg-deep); display: block !important;
width: 4px !important;
height: 4px !important;
} }
::-webkit-scrollbar-thumb { .with-scrollbar::-webkit-scrollbar-track {
background: var(--color-border-bright); background: var(--color-bg-deep) !important;
border-radius: 3px;
} }
::-webkit-scrollbar-thumb:hover { .with-scrollbar::-webkit-scrollbar-thumb {
background: var(--color-gold-dim); background: var(--color-border-bright) !important;
border-radius: 2px !important;
}
.with-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--color-gold-dim) !important;
} }
/* Checkbox custom */ /* Checkbox custom */

View File

@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App"; import App from "./App";
import ImageViewerWindow from "./components/ImageViewerWindow";
// Block all document-level scrolling // Block all document-level scrolling
document.addEventListener("wheel", (e) => { document.addEventListener("wheel", (e) => {
@ -17,8 +18,10 @@ document.addEventListener("scroll", () => {
window.scrollTo(0, 0); window.scrollTo(0, 0);
}, { passive: true }); }, { passive: true });
const isViewer = new URLSearchParams(window.location.search).get("viewer") === "1";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<App /> {isViewer ? <ImageViewerWindow /> : <App />}
</React.StrictMode>, </React.StrictMode>,
); );

View File

@ -9,11 +9,16 @@ interface AppState {
activeGuideGid: string | null; activeGuideGid: string | null;
activeGuideData: GuideData | null; activeGuideData: GuideData | null;
completedQuests: Set<string>; completedQuests: Set<string>;
alwaysOnTop: boolean;
syncing: boolean; syncing: boolean;
syncProgress: { current: number; total: number; label: string } | null; syncProgress: { current: number; total: number; label: string } | null;
view: "home" | "guide"; 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>; loadProfiles: () => Promise<void>;
setActiveProfile: (id: string) => Promise<void>; setActiveProfile: (id: string) => Promise<void>;
createProfile: (name: string) => Promise<void>; createProfile: (name: string) => Promise<void>;
@ -21,13 +26,13 @@ interface AppState {
loadGuides: () => Promise<void>; loadGuides: () => Promise<void>;
openGuide: (gid: string) => Promise<void>; openGuide: (gid: string) => Promise<void>;
setSidebarCollapsed: (collapsed: boolean) => void;
closeGuide: () => void; closeGuide: () => void;
toggleQuest: (questName: string) => Promise<void>; toggleQuest: (questName: string) => Promise<void>;
syncGuides: () => Promise<SyncResult>; syncGuides: () => Promise<SyncResult>;
syncSingleGuide: (gid: string, name: string) => Promise<void>; syncSingleGuide: (gid: string, name: string) => Promise<void>;
toggleAlwaysOnTop: () => Promise<void>;
} }
export const useStore = create<AppState>((set, get) => ({ export const useStore = create<AppState>((set, get) => ({
@ -37,10 +42,30 @@ export const useStore = create<AppState>((set, get) => ({
activeGuideGid: null, activeGuideGid: null,
activeGuideData: null, activeGuideData: null,
completedQuests: new Set(), completedQuests: new Set(),
alwaysOnTop: true,
syncing: false, syncing: false,
syncProgress: null, syncProgress: null,
view: "home", 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 () => { loadProfiles: async () => {
const profiles = await invoke<Profile[]>("get_profiles"); const profiles = await invoke<Profile[]>("get_profiles");
@ -50,6 +75,7 @@ export const useStore = create<AppState>((set, get) => ({
if (activeId) { if (activeId) {
const completed = await invoke<string[]>("get_completed_quests", { profileId: activeId }); const completed = await invoke<string[]>("get_completed_quests", { profileId: activeId });
set({ completedQuests: new Set(completed) }); 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 }); await invoke("set_setting", { key: "active_profile", value: id });
const completed = await invoke<string[]>("get_completed_quests", { profileId: id }); const completed = await invoke<string[]>("get_completed_quests", { profileId: id });
set({ activeProfileId: id, completedQuests: new Set(completed) }); set({ activeProfileId: id, completedQuests: new Set(completed) });
await get().loadGuides(); await Promise.all([get().loadGuides(), get().loadResourceInventory()]);
}, },
createProfile: async (name) => { createProfile: async (name) => {
@ -84,10 +110,14 @@ export const useStore = create<AppState>((set, get) => ({
openGuide: async (gid) => { openGuide: async (gid) => {
const data = await invoke<GuideData>("get_guide", { 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" }); set({ activeGuideGid: gid, activeGuideData: data, view: "guide" });
}, },
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
closeGuide: () => { closeGuide: () => {
invoke("set_setting", { key: "active_guide", value: "" });
set({ activeGuideGid: null, activeGuideData: null, view: "home" }); set({ activeGuideGid: null, activeGuideData: null, view: "home" });
}, },
@ -133,10 +163,4 @@ export const useStore = create<AppState>((set, get) => ({
syncSingleGuide: async (gid, name) => { syncSingleGuide: async (gid, name) => {
await invoke("sync_single_guide", { 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; synced: number;
errors: string[]; errors: string[];
} }
export interface QuestStep {
index: number;
text: string;
images: string[];
launch_position: string | null;
}