Compare commits
13 Commits
7de1fa2850
...
1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 55e7dc39f7 | |||
| 1c599c54fe | |||
| 7515380998 | |||
| 7bca85261a | |||
| 7262236b7d | |||
| 99748f56e8 | |||
| e391e9ff6a | |||
| 2984699033 | |||
| fe108a3b61 | |||
| a397c86bc3 | |||
| fb06def5aa | |||
| 3fb8e23c07 | |||
| 9d181f3676 |
84
.github/workflows/build.yml
vendored
Normal file
84
.github/workflows/build.yml
vendored
Normal 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
133
README.md
@ -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
|
||||
|
||||

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

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

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

|
||||
|
||||
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).
|
||||
|
||||
783
package-lock.json
generated
783
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -24,7 +24,7 @@
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"esbuild": "^0.28.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "~5.8.3",
|
||||
|
||||
61
release.sh
61
release.sh
@ -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
BIN
screenshots/accueil.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
BIN
screenshots/guide-argenté.png
Normal file
BIN
screenshots/guide-argenté.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
BIN
screenshots/quete-detail.png
Normal file
BIN
screenshots/quete-detail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
BIN
screenshots/settings.png
Normal file
BIN
screenshots/settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
118
src-tauri/Cargo.lock
generated
118
src-tauri/Cargo.lock
generated
@ -26,6 +26,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
@ -766,6 +767,19 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.31.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b3df4f93e5fbbe73ec01ec8d3f68bba73107993a5b1e7519273c32db9b0d5be"
|
||||
dependencies = [
|
||||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf 0.11.3",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.36.0"
|
||||
@ -1081,6 +1095,12 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ego-tree"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12a0bb14ac04a9fcf170d0bbbef949b44cc492f4452bd20c095636956f653642"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@ -1568,6 +1588,15 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.1.16"
|
||||
@ -1880,6 +1909,20 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"mac",
|
||||
"markup5ever 0.12.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.29.1"
|
||||
@ -2494,6 +2537,20 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45"
|
||||
dependencies = [
|
||||
"log",
|
||||
"phf 0.11.3",
|
||||
"phf_codegen 0.11.3",
|
||||
"string_cache 0.8.9",
|
||||
"string_cache_codegen 0.5.4",
|
||||
"tendril 0.4.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.14.1"
|
||||
@ -3092,6 +3149,16 @@ dependencies = [
|
||||
"phf_shared 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd"
|
||||
dependencies = [
|
||||
"phf_generator 0.10.0",
|
||||
"phf_shared 0.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.11.3"
|
||||
@ -4093,6 +4160,22 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "scraper"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b90460b31bfe1fc07be8262e42c665ad97118d4585869de9345a84d501a9eaf0"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"cssparser 0.31.2",
|
||||
"ego-tree",
|
||||
"getopts",
|
||||
"html5ever 0.27.0",
|
||||
"once_cell",
|
||||
"selectors 0.25.0",
|
||||
"tendril 0.4.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "seahash"
|
||||
version = "4.1.0"
|
||||
@ -4140,6 +4223,25 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"cssparser 0.31.2",
|
||||
"derive_more 0.99.20",
|
||||
"fxhash",
|
||||
"log",
|
||||
"new_debug_unreachable",
|
||||
"phf 0.10.1",
|
||||
"phf_codegen 0.10.0",
|
||||
"precomputed-hash",
|
||||
"servo_arc 0.3.0",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.36.1"
|
||||
@ -4339,6 +4441,15 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "servo_arc"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d036d71a959e00c77a63538b90a6c2390969f9772b096ea837205c6bd0491a44"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "servo_arc"
|
||||
version = "0.4.3"
|
||||
@ -5592,6 +5703,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
"scraper",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@ -5806,6 +5918,12 @@ version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
|
||||
@ -26,6 +26,7 @@ tokio = { version = "1", features = ["full"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
dirs-next = "2"
|
||||
scraper = "0.20"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
webkit2gtk = { version = "2.0", features = ["v2_38"] }
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"windows": ["main", "image-viewer"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
@ -21,6 +21,7 @@
|
||||
"core:window:allow-set-cursor-visible",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-is-minimized",
|
||||
"core:window:allow-is-focused"
|
||||
"core:window:allow-is-focused",
|
||||
"core:window:allow-start-resize-dragging"
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
use tauri::window::Color;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Mutex;
|
||||
use rusqlite::Connection;
|
||||
@ -173,6 +174,18 @@ pub fn has_guides(state: State<DbState>) -> Result<bool, String> {
|
||||
Ok(!guides.is_empty())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_resource_inventory(state: State<DbState>, profile_id: String) -> Result<Vec<(String, i64)>, String> {
|
||||
let conn = state.0.lock().map_err(|e| e.to_string())?;
|
||||
db::get_resource_inventory(&conn, &profile_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_resource_quantity(state: State<DbState>, profile_id: String, resource_name: String, quantity: i64) -> Result<(), String> {
|
||||
let conn = state.0.lock().map_err(|e| e.to_string())?;
|
||||
db::set_resource_quantity(&conn, &profile_id, &resource_name, quantity).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_setting(state: State<DbState>, key: String) -> Result<Option<String>, String> {
|
||||
let conn = state.0.lock().map_err(|e| e.to_string())?;
|
||||
@ -194,6 +207,355 @@ pub fn set_always_on_top(app: AppHandle, value: bool) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct QuestStep {
|
||||
pub index: usize,
|
||||
pub text: String,
|
||||
pub images: Vec<String>,
|
||||
pub launch_position: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fetch_quest_detail(url: String) -> Result<Vec<QuestStep>, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let html = client.get(&url)
|
||||
.send()
|
||||
.map_err(|e| format!("Erreur réseau : {}", e))?
|
||||
.text()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
parse_quest_steps(&html)
|
||||
}).await.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
fn parse_quest_steps(html: &str) -> Result<Vec<QuestStep>, String> {
|
||||
use scraper::{Html, Selector};
|
||||
|
||||
const BASE_URL: &str = "https://www.dofuspourlesnoobs.com";
|
||||
|
||||
let document = Html::parse_document(html);
|
||||
|
||||
let children_sel = Selector::parse("#wsite-content > div").unwrap();
|
||||
let para_sel = Selector::parse("div.paragraph, div.paragraphe").unwrap();
|
||||
let img_link_sel = Selector::parse("div.wsite-image a, div.wsite-image img").unwrap();
|
||||
// Blue = Position de lancement label; red = other info labels (Prérequis, Niveau…)
|
||||
let position_sel = Selector::parse("font[color='#3a96b8'], font[color='#3A96B8']").unwrap();
|
||||
let info_block_sel = Selector::parse(
|
||||
"font[color='#3a96b8'], font[color='#3A96B8'], font[color='#ff0000'], font[color='#FF0000']"
|
||||
).unwrap();
|
||||
|
||||
let mut steps: Vec<QuestStep> = Vec::new();
|
||||
let mut first = true;
|
||||
let mut header_done = false;
|
||||
|
||||
for child in document.select(&children_sel) {
|
||||
if first { first = false; continue; }
|
||||
|
||||
let v = child.value();
|
||||
|
||||
// ── Case A: the child IS itself a paragraph ──────────────────────────
|
||||
if v.classes().any(|c| c == "paragraph" || c == "paragraphe") {
|
||||
let text = element_to_text(&child).trim().to_string();
|
||||
if text.is_empty() || is_date_meta(&text) { continue; }
|
||||
|
||||
if !header_done {
|
||||
header_done = true;
|
||||
// Info block detected by colored font labels typical of DPLN info sections
|
||||
if child.select(&info_block_sel).next().is_some() {
|
||||
let pos = extract_launch_position(&child, &position_sel);
|
||||
if pos.is_some() {
|
||||
let images = collect_images_from(&child, &img_link_sel, BASE_URL);
|
||||
steps.push(QuestStep { index: steps.len(), text: String::new(), images, launch_position: pos });
|
||||
}
|
||||
continue; // always skip as a regular step
|
||||
}
|
||||
}
|
||||
|
||||
steps.push(QuestStep { index: steps.len(), text, images: vec![], launch_position: None });
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Cases B & C: the child is a wrapper div ───────────────────────
|
||||
let inner_paras: Vec<_> = child.select(¶_sel).collect();
|
||||
let images = collect_images_from(&child, &img_link_sel, BASE_URL);
|
||||
|
||||
if inner_paras.is_empty() {
|
||||
if !images.is_empty() {
|
||||
if let Some(last) = steps.last_mut() {
|
||||
last.images.extend(images);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut first_para = true;
|
||||
for para in &inner_paras {
|
||||
let text = element_to_text(para).trim().to_string();
|
||||
if text.is_empty() || is_date_meta(&text) { continue; }
|
||||
|
||||
if !header_done && first_para {
|
||||
header_done = true;
|
||||
if para.select(&info_block_sel).next().is_some() {
|
||||
let pos = extract_launch_position(para, &position_sel);
|
||||
if pos.is_some() {
|
||||
let imgs = images.clone();
|
||||
steps.push(QuestStep { index: steps.len(), text: String::new(), images: imgs, launch_position: pos });
|
||||
}
|
||||
first_para = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let imgs = if first_para { images.clone() } else { vec![] };
|
||||
first_para = false;
|
||||
steps.push(QuestStep { index: steps.len(), text, images: imgs, launch_position: None });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(steps)
|
||||
}
|
||||
|
||||
fn extract_launch_position(para: &scraper::ElementRef, position_sel: &scraper::Selector) -> Option<String> {
|
||||
for font_el in para.select(position_sel) {
|
||||
let label: String = font_el.text().collect();
|
||||
if !label.to_lowercase().contains("position") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let tree = font_el.tree();
|
||||
let para_id = para.id();
|
||||
let mut current_id = font_el.id();
|
||||
|
||||
// Walk up from font_el toward para, collecting text from siblings after each ancestor.
|
||||
// Necessary because the value <span> is a sibling of <strong> (font's grandparent),
|
||||
// not a sibling of <font> itself.
|
||||
loop {
|
||||
let current_node = tree.get(current_id)?;
|
||||
let parent_node = current_node.parent()?;
|
||||
let parent_id = parent_node.id();
|
||||
|
||||
let mut after_current = false;
|
||||
let mut result = String::new();
|
||||
|
||||
for sibling in parent_node.children() {
|
||||
if sibling.id() == current_id {
|
||||
after_current = true;
|
||||
continue;
|
||||
}
|
||||
if !after_current { continue; }
|
||||
|
||||
// A new <strong> means a new field — stop collecting
|
||||
if let scraper::Node::Element(e) = sibling.value() {
|
||||
if e.name() == "strong" { break; }
|
||||
}
|
||||
|
||||
if let Some(elem) = scraper::ElementRef::wrap(sibling) {
|
||||
let t: String = elem.text().collect();
|
||||
let t = t.trim().to_string();
|
||||
if !t.is_empty() {
|
||||
if !result.is_empty() { result.push(' '); }
|
||||
result.push_str(&t);
|
||||
}
|
||||
} else if let scraper::Node::Text(t) = sibling.value() {
|
||||
let s = t.text.trim();
|
||||
if !s.is_empty() {
|
||||
if !result.is_empty() { result.push(' '); }
|
||||
result.push_str(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pos = result.trim().trim_end_matches('.').trim().to_string();
|
||||
if !pos.is_empty() {
|
||||
return Some(pos);
|
||||
}
|
||||
|
||||
if parent_id == para_id {
|
||||
break;
|
||||
}
|
||||
current_id = parent_id;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn collect_images_from(
|
||||
el: &scraper::ElementRef,
|
||||
img_link_sel: &scraper::Selector,
|
||||
base_url: &str,
|
||||
) -> Vec<String> {
|
||||
let mut images = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for img_el in el.select(img_link_sel) {
|
||||
let url_opt = img_el.value().attr("href")
|
||||
.filter(|u| is_image_url(u))
|
||||
.or_else(|| img_el.value().attr("src").filter(|u| is_image_url(u)));
|
||||
if let Some(url) = url_opt {
|
||||
let absolute = if url.starts_with('/') {
|
||||
format!("{}{}", base_url, url)
|
||||
} else {
|
||||
url.to_string()
|
||||
};
|
||||
if seen.insert(absolute.clone()) {
|
||||
images.push(absolute);
|
||||
}
|
||||
}
|
||||
}
|
||||
images
|
||||
}
|
||||
|
||||
fn is_date_meta(text: &str) -> bool {
|
||||
let lower = text.to_lowercase();
|
||||
// Matches Weebly blog metadata lines like "Publié le 01/01/2024" or "Mis à jour le …"
|
||||
let is_meta_word = lower.starts_with("publi")
|
||||
|| lower.starts_with("mis à jour")
|
||||
|| lower.starts_with("mis en ligne")
|
||||
|| lower.starts_with("modifi")
|
||||
|| lower.starts_with("rédigé")
|
||||
|| lower.starts_with("redige");
|
||||
// Also catch bare date lines like "01/01/2024" or "2024-01-01"
|
||||
let digit_count = text.chars().filter(|c| c.is_ascii_digit()).count();
|
||||
let sep_count = text.chars().filter(|&c| c == '/' || c == '-').count();
|
||||
let is_bare_date = text.len() < 40 && digit_count >= 6 && sep_count >= 2;
|
||||
is_meta_word || is_bare_date
|
||||
}
|
||||
|
||||
fn is_image_url(url: &str) -> bool {
|
||||
url.contains("uploads")
|
||||
|| url.ends_with(".jpg")
|
||||
|| url.ends_with(".jpeg")
|
||||
|| url.ends_with(".png")
|
||||
|| url.ends_with(".webp")
|
||||
}
|
||||
|
||||
fn element_to_text(el: &scraper::ElementRef) -> String {
|
||||
let mut out = String::new();
|
||||
for node in el.descendants() {
|
||||
match node.value() {
|
||||
scraper::Node::Text(t) => {
|
||||
let s = t.text.trim();
|
||||
if !s.is_empty() {
|
||||
if !out.is_empty() && !out.ends_with('\n') && !out.ends_with(' ') {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(s);
|
||||
}
|
||||
}
|
||||
scraper::Node::Element(e) => {
|
||||
if matches!(e.name(), "script" | "style" | "noscript") { continue; }
|
||||
if matches!(e.name(), "p" | "br" | "h1" | "h2" | "h3" | "h4" | "li" | "div" | "tr") {
|
||||
if !out.is_empty() && !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Collapse multiple blank lines
|
||||
let mut result = String::new();
|
||||
let mut prev_empty = false;
|
||||
for line in out.lines() {
|
||||
let t = line.trim();
|
||||
if t.is_empty() {
|
||||
if !prev_empty { result.push('\n'); }
|
||||
prev_empty = true;
|
||||
} else {
|
||||
result.push_str(t);
|
||||
result.push('\n');
|
||||
prev_empty = false;
|
||||
}
|
||||
}
|
||||
result.trim().to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_completed_steps(
|
||||
state: State<DbState>,
|
||||
profile_id: String,
|
||||
quest_name: String,
|
||||
) -> Result<Vec<i64>, String> {
|
||||
let conn = state.0.lock().map_err(|e| e.to_string())?;
|
||||
db::get_completed_steps(&conn, &profile_id, &quest_name).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn toggle_quest_step(
|
||||
state: State<DbState>,
|
||||
profile_id: String,
|
||||
quest_name: String,
|
||||
step_index: i64,
|
||||
) -> Result<bool, String> {
|
||||
let conn = state.0.lock().map_err(|e| e.to_string())?;
|
||||
db::toggle_quest_step(&conn, &profile_id, &quest_name, step_index).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn percent_encode(s: &str) -> String {
|
||||
let mut result = String::new();
|
||||
for byte in s.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
result.push(byte as char);
|
||||
}
|
||||
b => result.push_str(&format!("%{:02X}", b)),
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_image_viewer(
|
||||
app: AppHandle,
|
||||
state: State<'_, DbState>,
|
||||
image_url: String,
|
||||
) -> Result<(), String> {
|
||||
if let Some(win) = app.get_webview_window("image-viewer") {
|
||||
win.emit("set-viewer-image", &image_url).map_err(|e| e.to_string())?;
|
||||
win.set_focus().map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (w, h, x, y) = {
|
||||
let conn = state.0.lock().map_err(|e| e.to_string())?;
|
||||
let w: f64 = db::get_setting(&conn, "viewer_width").and_then(|v| v.parse().ok()).unwrap_or(600.0);
|
||||
let h: f64 = db::get_setting(&conn, "viewer_height").and_then(|v| v.parse().ok()).unwrap_or(500.0);
|
||||
let x: Option<f64> = db::get_setting(&conn, "viewer_x").and_then(|v| v.parse().ok());
|
||||
let y: Option<f64> = db::get_setting(&conn, "viewer_y").and_then(|v| v.parse().ok());
|
||||
(w, h, x, y)
|
||||
};
|
||||
|
||||
let path = format!("/?viewer=1&imageUrl={}", percent_encode(&image_url));
|
||||
let mut builder = tauri::WebviewWindowBuilder::new(
|
||||
&app,
|
||||
"image-viewer",
|
||||
tauri::WebviewUrl::App(path.into()),
|
||||
)
|
||||
.title("Image")
|
||||
.decorations(false)
|
||||
.resizable(true)
|
||||
.always_on_top(true)
|
||||
.background_color(Color(13, 17, 23, 255))
|
||||
.inner_size(w, h);
|
||||
|
||||
if let (Some(x), Some(y)) = (x, y) {
|
||||
builder = builder.position(x, y);
|
||||
}
|
||||
|
||||
let viewer = builder.build().map_err(|e| e.to_string())?;
|
||||
viewer.eval(r#"(function(){
|
||||
var s=document.createElement('style');
|
||||
s.textContent='::-webkit-scrollbar{display:none!important;width:0!important;height:0!important}*{scrollbar-width:none!important}';
|
||||
var apply=function(){if(document.head)document.head.appendChild(s)};
|
||||
if(document.head)apply();else document.addEventListener('DOMContentLoaded',apply);
|
||||
})();"#).ok();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_quest_names(data: &parser::GuideData) -> Vec<String> {
|
||||
let mut names = Vec::new();
|
||||
for section in &data.sections {
|
||||
|
||||
@ -60,6 +60,22 @@ pub fn migrate(conn: &Connection) -> Result<()> {
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS quest_step_progress (
|
||||
profile_id TEXT NOT NULL,
|
||||
quest_name TEXT NOT NULL,
|
||||
step_index INTEGER NOT NULL,
|
||||
PRIMARY KEY (profile_id, quest_name, step_index),
|
||||
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS resource_inventory (
|
||||
profile_id TEXT NOT NULL,
|
||||
resource_name TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (profile_id, resource_name),
|
||||
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
")?;
|
||||
Ok(())
|
||||
}
|
||||
@ -145,6 +161,62 @@ pub fn get_guides(conn: &Connection) -> Result<Vec<GuideRow>> {
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn get_completed_steps(conn: &Connection, profile_id: &str, quest_name: &str) -> Result<Vec<i64>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT step_index FROM quest_step_progress WHERE profile_id = ?1 AND quest_name = ?2"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![profile_id, quest_name], |row| row.get(0))?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn toggle_quest_step(conn: &Connection, profile_id: &str, quest_name: &str, step_index: i64) -> Result<bool> {
|
||||
let exists: bool = conn.query_row(
|
||||
"SELECT COUNT(*) FROM quest_step_progress WHERE profile_id = ?1 AND quest_name = ?2 AND step_index = ?3",
|
||||
params![profile_id, quest_name, step_index],
|
||||
|row| row.get::<_, i64>(0),
|
||||
).map(|c| c > 0)?;
|
||||
|
||||
if exists {
|
||||
conn.execute(
|
||||
"DELETE FROM quest_step_progress WHERE profile_id = ?1 AND quest_name = ?2 AND step_index = ?3",
|
||||
params![profile_id, quest_name, step_index],
|
||||
)?;
|
||||
Ok(false)
|
||||
} else {
|
||||
conn.execute(
|
||||
"INSERT INTO quest_step_progress (profile_id, quest_name, step_index) VALUES (?1, ?2, ?3)",
|
||||
params![profile_id, quest_name, step_index],
|
||||
)?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_resource_inventory(conn: &Connection, profile_id: &str) -> Result<Vec<(String, i64)>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT resource_name, quantity FROM resource_inventory WHERE profile_id = ?1"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![profile_id], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn set_resource_quantity(conn: &Connection, profile_id: &str, resource_name: &str, quantity: i64) -> Result<()> {
|
||||
if quantity <= 0 {
|
||||
conn.execute(
|
||||
"DELETE FROM resource_inventory WHERE profile_id = ?1 AND resource_name = ?2",
|
||||
params![profile_id, resource_name],
|
||||
)?;
|
||||
} else {
|
||||
conn.execute(
|
||||
"INSERT INTO resource_inventory (profile_id, resource_name, quantity) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(profile_id, resource_name) DO UPDATE SET quantity=excluded.quantity",
|
||||
params![profile_id, resource_name, quantity],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_setting(conn: &Connection, key: &str) -> Option<String> {
|
||||
conn.query_row(
|
||||
"SELECT value FROM settings WHERE key = ?1",
|
||||
|
||||
@ -21,6 +21,12 @@ pub fn run() {
|
||||
use webkit2gtk::WebViewExt;
|
||||
|
||||
let window = app.get_webview_window("main").expect("no main window");
|
||||
window.eval(r#"(function(){
|
||||
var s=document.createElement('style');
|
||||
s.textContent='*:not(.with-scrollbar)::-webkit-scrollbar{display:none!important;width:0!important;height:0!important}*:not(.with-scrollbar){scrollbar-width:none!important}';
|
||||
var apply=function(){if(document.head)document.head.appendChild(s)};
|
||||
if(document.head)apply();else document.addEventListener('DOMContentLoaded',apply);
|
||||
})();"#).ok();
|
||||
window.with_webview(|wv| {
|
||||
let webkit_view = wv.inner();
|
||||
if let Some(parent) = webkit_view.parent() {
|
||||
@ -48,6 +54,12 @@ pub fn run() {
|
||||
commands::get_setting,
|
||||
commands::set_setting,
|
||||
commands::set_always_on_top,
|
||||
commands::fetch_quest_detail,
|
||||
commands::get_completed_steps,
|
||||
commands::toggle_quest_step,
|
||||
commands::get_resource_inventory,
|
||||
commands::set_resource_quantity,
|
||||
commands::open_image_viewer,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@ -15,8 +15,8 @@
|
||||
"title": "TougliGui",
|
||||
"width": 1100,
|
||||
"height": 720,
|
||||
"minWidth": 800,
|
||||
"minHeight": 500,
|
||||
"minWidth": 300,
|
||||
"minHeight": 400,
|
||||
"decorations": false,
|
||||
"transparent": false,
|
||||
"backgroundColor": "#0d1117",
|
||||
|
||||
73
src/App.tsx
73
src/App.tsx
@ -1,29 +1,86 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrentWindow, LogicalSize } from "@tauri-apps/api/window";
|
||||
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { useStore } from "./store";
|
||||
import TitleBar from "./components/TitleBar";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import ResizeHandles from "./components/ResizeHandles";
|
||||
import HomeView from "./components/HomeView";
|
||||
import GuideView from "./components/GuideView";
|
||||
import ProfileModal from "./components/ProfileModal";
|
||||
import SettingsPanel from "./components/SettingsPanel";
|
||||
import SyncOverlay from "./components/SyncOverlay";
|
||||
|
||||
export default function App() {
|
||||
const { loadProfiles, loadGuides, view, syncing, syncGuides } = useStore();
|
||||
const [showProfileModal, setShowProfileModal] = useState(false);
|
||||
const { loadProfiles, loadGuides, openGuide, setResourcesPanelCollapsed, view, syncing, syncGuides } = useStore();
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [needsSync, setNeedsSync] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
// Restore window size
|
||||
const [savedW, savedH] = await Promise.all([
|
||||
invoke<string | null>("get_setting", { key: "window_width" }),
|
||||
invoke<string | null>("get_setting", { key: "window_height" }),
|
||||
]);
|
||||
if (savedW && savedH) {
|
||||
await getCurrentWindow().setSize(new LogicalSize(parseInt(savedW), parseInt(savedH)));
|
||||
}
|
||||
|
||||
await loadProfiles();
|
||||
|
||||
const has = await invoke<boolean>("has_guides");
|
||||
if (!has) {
|
||||
setNeedsSync(true);
|
||||
} else {
|
||||
await loadGuides();
|
||||
// Restore last viewed guide
|
||||
const lastGuide = await invoke<string | null>("get_setting", { key: "active_guide" });
|
||||
if (lastGuide) {
|
||||
try {
|
||||
await openGuide(lastGuide);
|
||||
setResourcesPanelCollapsed(true);
|
||||
} catch { /* guide may no longer exist */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
init();
|
||||
|
||||
// Persist window size on resize (debounced)
|
||||
const win = getCurrentWindow();
|
||||
let debounce: ReturnType<typeof setTimeout> | null = null;
|
||||
const unlisten = win.onResized(async () => {
|
||||
if (debounce !== null) clearTimeout(debounce);
|
||||
debounce = setTimeout(async () => {
|
||||
const size = await win.innerSize();
|
||||
const factor = await win.scaleFactor();
|
||||
const w = Math.round(size.width / factor);
|
||||
const h = Math.round(size.height / factor);
|
||||
await invoke("set_setting", { key: "window_width", value: w.toString() });
|
||||
await invoke("set_setting", { key: "window_height", value: h.toString() });
|
||||
}, 500);
|
||||
});
|
||||
|
||||
const unlistenFocus = win.listen("tauri://focus", async () => {
|
||||
const viewer = await WebviewWindow.getByLabel("image-viewer");
|
||||
if (viewer) {
|
||||
const isMin = await viewer.isMinimized();
|
||||
if (isMin) await viewer.unminimize();
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenBlur = win.listen("tauri://blur", async () => {
|
||||
const isMin = await win.isMinimized();
|
||||
if (isMin) {
|
||||
const viewer = await WebviewWindow.getByLabel("image-viewer");
|
||||
if (viewer) await viewer.minimize();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then(f => f());
|
||||
unlistenFocus.then(f => f());
|
||||
unlistenBlur.then(f => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleInitialSync() {
|
||||
@ -33,16 +90,16 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<TitleBar onOpenProfiles={() => setShowProfileModal(true)} />
|
||||
<ResizeHandles />
|
||||
<TitleBar onOpenSettings={() => setShowSettings(s => !s)} />
|
||||
<div className="app-body">
|
||||
<Sidebar />
|
||||
<main className="app-main">
|
||||
{view === "home" ? <HomeView /> : <GuideView />}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{showProfileModal && <ProfileModal onClose={() => setShowProfileModal(false)} />}
|
||||
{syncing && <SyncOverlay />}
|
||||
{showSettings && <SettingsPanel onClose={() => setShowSettings(false)} />}
|
||||
{syncing && !showSettings && <SyncOverlay />}
|
||||
{needsSync && (
|
||||
<div style={{
|
||||
position: "fixed", inset: 0, background: "rgba(0,0,0,0.85)",
|
||||
|
||||
@ -1,63 +1,111 @@
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useStore } from "../store";
|
||||
import { SectionItem, QuestItem } from "../types";
|
||||
import { SectionItem, QuestItem, CombatType } from "../types";
|
||||
import QuestDetailPanel from "./QuestDetailPanel";
|
||||
import { TextWithCoords } from "./TextWithCoords";
|
||||
|
||||
function useWindowWidth() {
|
||||
const [width, setWidth] = useState(window.innerWidth);
|
||||
useEffect(() => {
|
||||
const handler = () => setWidth(window.innerWidth);
|
||||
window.addEventListener("resize", handler);
|
||||
return () => window.removeEventListener("resize", handler);
|
||||
}, []);
|
||||
return width;
|
||||
}
|
||||
|
||||
function combatIcon(name: string): string {
|
||||
const l = name.toLowerCase();
|
||||
if (l.includes("solo") || l.includes("seul")) return "🗡️";
|
||||
if (l.includes("group") || l.includes("groupe")) return "⚔️";
|
||||
if (l.includes("donjon") || l.includes("boss")) return "💀";
|
||||
return "🗡️";
|
||||
}
|
||||
|
||||
export default function GuideView() {
|
||||
const { activeGuideData, completedQuests, toggleQuest } = useStore();
|
||||
const { activeGuideData, completedQuests, toggleQuest, activeProfileId, resourcesPanelCollapsed, setResourcesPanelCollapsed, resourceInventory, setResourceQuantity } = useStore();
|
||||
const resourcesCollapsed = resourcesPanelCollapsed;
|
||||
const setResourcesCollapsed = setResourcesPanelCollapsed;
|
||||
const [selectedQuest, setSelectedQuest] = useState<{ name: string; url: string | null } | null>(null);
|
||||
const windowWidth = useWindowWidth();
|
||||
const resourcesIsOverlay = resourcesCollapsed || windowWidth < 500;
|
||||
|
||||
if (!activeGuideData) return null;
|
||||
|
||||
const { name, effect, recommended_level, resources, sections } = activeGuideData;
|
||||
if (selectedQuest && activeProfileId) {
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", overflow: "hidden", minHeight: 0 }}>
|
||||
<QuestDetailPanel
|
||||
questName={selectedQuest.name}
|
||||
questUrl={selectedQuest.url}
|
||||
profileId={activeProfileId}
|
||||
onClose={() => setSelectedQuest(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { name, effect, recommended_level, resources, sections, combat_legend } = activeGuideData;
|
||||
|
||||
const allQuests = collectAllQuests(sections);
|
||||
const completedCount = allQuests.filter(q => completedQuests.has(q)).length;
|
||||
const pct = allQuests.length > 0 ? Math.round((completedCount / allQuests.length) * 100) : 0;
|
||||
const isDone = pct === 100;
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", overflow: "hidden", minHeight: 0 }}>
|
||||
{/* Main quest list */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "20px 24px" }}>
|
||||
{/* Guide header */}
|
||||
<div style={{ flex: 1, display: "flex", overflow: "hidden", minHeight: 0, position: "relative" }}>
|
||||
<div className="with-scrollbar" style={{ flex: 1, overflowY: "auto", padding: "20px 24px" }}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<h1 style={{ fontSize: "20px", fontWeight: 700, color: "#f0c040", marginBottom: "4px" }}>
|
||||
{name}
|
||||
</h1>
|
||||
<div style={{ display: "flex", gap: "16px", alignItems: "center", marginBottom: "10px" }}>
|
||||
{recommended_level && (
|
||||
<span style={{
|
||||
fontSize: "11px", background: "rgba(74,158,255,0.15)", color: "#4a9eff",
|
||||
border: "1px solid rgba(74,158,255,0.3)", borderRadius: "4px", padding: "2px 8px",
|
||||
}}>
|
||||
Niveau recommandé : {recommended_level}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: "11px", color: "#94a3b8" }}>
|
||||
{completedCount}/{allQuests.length} quêtes · {pct}%
|
||||
</span>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: "8px", flexWrap: "wrap" }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h1 style={{ fontSize: "18px", fontWeight: 700, color: "#f0c040", marginBottom: "2px", wordBreak: "break-word" }}>{name}</h1>
|
||||
{recommended_level && (
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>
|
||||
Niv. recommandé : <span style={{ color: "#e2e8f0", fontWeight: 600 }}>{recommended_level}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ textAlign: "right", flexShrink: 0 }}>
|
||||
<div style={{ fontSize: "13px", fontWeight: 700, color: isDone ? "#4ade80" : "#f0c040" }}>
|
||||
{completedCount} / {allQuests.length}
|
||||
</div>
|
||||
<div style={{ fontSize: "11px", color: "#94a3b8" }}>{pct}%</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div style={{ height: "4px", background: "#2d3748", borderRadius: "2px", overflow: "hidden" }}>
|
||||
|
||||
<div style={{ height: "4px", background: "#2d3748", borderRadius: "2px", overflow: "hidden", marginTop: "10px" }}>
|
||||
<div style={{
|
||||
height: "100%", width: `${pct}%`,
|
||||
background: pct === 100 ? "#4ade80" : "linear-gradient(90deg, #4a9eff, #f0c040)",
|
||||
background: isDone ? "#4ade80" : "linear-gradient(90deg, #4a9eff, #f0c040)",
|
||||
borderRadius: "2px", transition: "width 0.3s ease",
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{effect && (
|
||||
<div style={{
|
||||
marginTop: "10px", background: "rgba(240,192,64,0.05)",
|
||||
border: "1px solid rgba(240,192,64,0.2)", borderRadius: "6px",
|
||||
padding: "8px 12px", fontSize: "11px", color: "#94a3b8", lineHeight: 1.5,
|
||||
marginTop: "12px", padding: "10px 14px",
|
||||
background: "rgba(240,192,64,0.04)", borderRadius: "6px",
|
||||
borderLeft: "3px solid rgba(240,192,64,0.4)",
|
||||
fontSize: "12px", color: "#94a3b8", lineHeight: 1.6,
|
||||
}}>
|
||||
<span style={{ color: "#f0c040", fontWeight: 600 }}>Effet : </span>
|
||||
<span style={{ color: "#f0c040", fontWeight: 600, fontSize: "11px", display: "block", marginBottom: "3px", textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
✨ Effet
|
||||
</span>
|
||||
{effect}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Légende */}
|
||||
{combat_legend.length > 0 && (
|
||||
<Legend legend={combat_legend} />
|
||||
)}
|
||||
|
||||
{/* Sections */}
|
||||
{sections.map((section, si) => (
|
||||
<div key={si} style={{ marginBottom: "20px" }}>
|
||||
<div key={si} style={{ marginBottom: "24px" }}>
|
||||
<h2 style={{
|
||||
fontSize: "11px", fontWeight: 600, color: "#4a5568",
|
||||
textTransform: "uppercase", letterSpacing: "0.1em",
|
||||
@ -66,7 +114,7 @@ export default function GuideView() {
|
||||
{section.name}
|
||||
</h2>
|
||||
{section.items.map((item, ii) => (
|
||||
<SectionItemView key={ii} item={item} completedQuests={completedQuests} onToggle={toggleQuest} />
|
||||
<SectionItemView key={ii} item={item} completedQuests={completedQuests} onToggle={toggleQuest} onSelect={setSelectedQuest} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
@ -75,35 +123,144 @@ export default function GuideView() {
|
||||
{/* Resources panel */}
|
||||
{resources.length > 0 && (
|
||||
<div style={{
|
||||
width: "200px", flexShrink: 0, background: "#161b22",
|
||||
borderLeft: "1px solid #2d3748", overflowY: "auto", padding: "16px 14px",
|
||||
position: resourcesIsOverlay ? "absolute" : "relative",
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
zIndex: 10,
|
||||
width: resourcesCollapsed ? "36px" : "190px",
|
||||
flexShrink: 0,
|
||||
background: resourcesCollapsed ? "transparent" : "#161b22",
|
||||
borderLeft: resourcesCollapsed ? "none" : "1px solid #2d3748",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
transition: "width 0.2s ease, background 0.2s ease",
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: "11px", fontWeight: 600, color: "#4a5568",
|
||||
textTransform: "uppercase", letterSpacing: "0.1em", marginBottom: "10px",
|
||||
}}>
|
||||
Ressources
|
||||
</h3>
|
||||
{resources.map((r, i) => (
|
||||
<div key={i} style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
padding: "4px 0", borderBottom: "1px solid #1f2937",
|
||||
fontSize: "11px",
|
||||
{/* Toggle */}
|
||||
<button
|
||||
onClick={() => setResourcesCollapsed(!resourcesCollapsed)}
|
||||
title={resourcesCollapsed ? "Afficher les ressources" : "Masquer les ressources"}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "36px",
|
||||
flexShrink: 0,
|
||||
background: resourcesCollapsed ? "rgba(22,27,34,0.9)" : "transparent",
|
||||
border: resourcesCollapsed ? "1px solid #2d3748" : "none",
|
||||
borderRight: "none",
|
||||
borderRadius: resourcesCollapsed ? "6px 0 0 6px" : "0",
|
||||
borderBottom: "1px solid #2d3748",
|
||||
color: "#4a5568",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: resourcesCollapsed ? "center" : "flex-start",
|
||||
padding: "0 10px",
|
||||
gap: "6px",
|
||||
marginTop: resourcesCollapsed ? "8px" : "0",
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = "#f0c040")}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = "#4a5568")}
|
||||
>
|
||||
<span style={{
|
||||
fontSize: "12px",
|
||||
transform: resourcesCollapsed ? "rotate(180deg)" : "rotate(0deg)",
|
||||
transition: "transform 0.2s ease",
|
||||
display: "inline-block",
|
||||
}}>
|
||||
<span style={{ color: "#94a3b8", flex: 1, marginRight: "8px" }}>{r.name}</span>
|
||||
<span style={{ color: "#f0c040", fontWeight: 700, flexShrink: 0 }}>×{r.quantity}</span>
|
||||
›
|
||||
</span>
|
||||
{!resourcesCollapsed && (
|
||||
<span style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.1em", whiteSpace: "nowrap" }}>
|
||||
Ressources
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* List */}
|
||||
{!resourcesCollapsed && (
|
||||
<div className="with-scrollbar" style={{ flex: 1, overflowY: "auto", padding: "10px 14px" }}>
|
||||
{resources.map((r, i) => {
|
||||
const owned = resourceInventory[r.name] ?? 0;
|
||||
const done = owned >= r.quantity;
|
||||
return (
|
||||
<div key={i} style={{
|
||||
padding: "6px 0", borderBottom: "1px solid #1f2937", fontSize: "12px",
|
||||
}}>
|
||||
<span style={{
|
||||
color: done ? "#4ade80" : "#94a3b8",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
wordBreak: "break-word",
|
||||
marginBottom: "3px",
|
||||
} as React.CSSProperties}>
|
||||
{r.name}
|
||||
</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={owned === 0 ? "" : owned}
|
||||
placeholder="0"
|
||||
onChange={e => {
|
||||
const v = parseInt(e.target.value);
|
||||
setResourceQuantity(r.name, isNaN(v) ? 0 : Math.max(0, v));
|
||||
}}
|
||||
style={{
|
||||
width: "42px", background: "#0d1117",
|
||||
border: `1px solid ${done ? "rgba(74,222,128,0.4)" : "#2d3748"}`,
|
||||
borderRadius: "4px", padding: "2px 4px",
|
||||
color: done ? "#4ade80" : "#e2e8f0",
|
||||
fontSize: "11px", outline: "none", textAlign: "right",
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: done ? "#4ade80" : "#f0c040", fontWeight: 700, flexShrink: 0 }}>
|
||||
/ ×{r.quantity}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionItemView({ item, completedQuests, onToggle }: {
|
||||
function Legend({ legend }: { legend: CombatType[] }) {
|
||||
return (
|
||||
<div style={{
|
||||
marginBottom: "20px", padding: "12px 16px",
|
||||
background: "rgba(255,255,255,0.02)", border: "1px solid #2d3748", borderRadius: "8px",
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: "11px", fontWeight: 600, color: "#4a5568",
|
||||
textTransform: "uppercase", letterSpacing: "0.1em", marginBottom: "10px",
|
||||
}}>
|
||||
Légende
|
||||
</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "16px" }}>
|
||||
{legend.map((ct, i) => (
|
||||
<div key={i} style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||
<span style={{ fontSize: "15px" }}>{combatIcon(ct.name)}</span>
|
||||
<span style={{ fontSize: "12px", color: "#94a3b8" }}>{ct.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionItemView({ item, completedQuests, onToggle, onSelect }: {
|
||||
item: SectionItem;
|
||||
completedQuests: Set<string>;
|
||||
onToggle: (name: string) => void;
|
||||
onSelect: (quest: { name: string; url: string | null }) => void;
|
||||
}) {
|
||||
if (item.type === "Instruction") {
|
||||
if (item.text.startsWith("__ZONE__:")) {
|
||||
@ -121,11 +278,16 @@ function SectionItemView({ item, completedQuests, onToggle }: {
|
||||
}
|
||||
return (
|
||||
<div style={{
|
||||
background: "rgba(74,158,255,0.05)", border: "1px solid rgba(74,158,255,0.15)",
|
||||
borderRadius: "6px", padding: "8px 12px", marginBottom: "4px",
|
||||
fontSize: "11px", color: "#94a3b8", lineHeight: 1.5,
|
||||
marginBottom: "6px", padding: "9px 14px",
|
||||
background: "rgba(74,158,255,0.04)",
|
||||
borderLeft: "3px solid rgba(74,158,255,0.35)",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px", color: "#94a3b8", lineHeight: 1.6,
|
||||
}}>
|
||||
ℹ️ {item.text}
|
||||
<span style={{ color: "#4a9eff", fontSize: "10px", fontWeight: 600, display: "block", marginBottom: "2px", textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
Rappel
|
||||
</span>
|
||||
<TextWithCoords text={item.text} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -137,98 +299,74 @@ function SectionItemView({ item, completedQuests, onToggle }: {
|
||||
borderRadius: "6px", padding: "8px 10px", marginBottom: "6px",
|
||||
}}>
|
||||
{item.note && (
|
||||
<div style={{
|
||||
fontSize: "10px", color: "#4a9eff", marginBottom: "6px",
|
||||
fontStyle: "italic",
|
||||
}}>
|
||||
<div style={{ fontSize: "11px", color: "#4a9eff", marginBottom: "6px", fontStyle: "italic" }}>
|
||||
🔗 {item.note}
|
||||
</div>
|
||||
)}
|
||||
{item.quests.map((q, i) => (
|
||||
<QuestRow key={i} quest={q} indent completed={completedQuests.has(q.name)} onToggle={onToggle} />
|
||||
<QuestRow key={i} quest={q} indent completed={completedQuests.has(q.name)} onToggle={onToggle} onSelect={onSelect} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "Quest") {
|
||||
return <QuestRow quest={item} completed={completedQuests.has(item.name)} onToggle={onToggle} />;
|
||||
return <QuestRow quest={item} completed={completedQuests.has(item.name)} onToggle={onToggle} onSelect={onSelect} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function QuestRow({ quest, completed, onToggle, indent }: {
|
||||
function QuestRow({ quest, completed, onToggle, onSelect, indent }: {
|
||||
quest: QuestItem;
|
||||
completed: boolean;
|
||||
onToggle: (name: string) => void;
|
||||
onSelect: (quest: { name: string; url: string | null }) => void;
|
||||
indent?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onToggle(quest.name)}
|
||||
style={{
|
||||
display: "flex", alignItems: "flex-start", gap: "8px",
|
||||
padding: indent ? "4px 0" : "5px 6px",
|
||||
borderRadius: "5px", cursor: "pointer",
|
||||
marginBottom: indent ? "2px" : "3px",
|
||||
opacity: completed ? 0.6 : 1,
|
||||
padding: indent ? "3px 0" : "4px 6px",
|
||||
borderRadius: "5px",
|
||||
marginBottom: indent ? "1px" : "2px",
|
||||
opacity: completed ? 0.5 : 1,
|
||||
transition: "all 0.12s",
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.04)";
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.04)"; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={completed}
|
||||
onChange={() => onToggle(quest.name)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ marginTop: "2px", flexShrink: 0 }}
|
||||
style={{ marginTop: "2px", flexShrink: 0, cursor: "pointer" }}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "5px", flexWrap: "wrap" }}>
|
||||
<span style={{
|
||||
fontSize: "12px", color: completed ? "#4a5568" : "#e2e8f0",
|
||||
textDecoration: completed ? "line-through" : "none",
|
||||
lineHeight: 1.4,
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", flexWrap: "wrap", gap: "2px 8px" }}>
|
||||
<span
|
||||
onClick={() => onSelect({ name: quest.name, url: quest.url })}
|
||||
style={{
|
||||
fontSize: "12px", lineHeight: 1.4,
|
||||
color: completed ? "#4a5568" : "#93c5fd",
|
||||
textDecoration: completed ? "line-through" : "underline",
|
||||
textDecorationColor: "rgba(147,197,253,0.3)",
|
||||
cursor: "pointer",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{quest.name}
|
||||
</span>
|
||||
{quest.url && (
|
||||
<span
|
||||
title="Voir sur Dofus Pour Les Noobs"
|
||||
onClick={e => { e.stopPropagation(); openUrl(quest.url!); }}
|
||||
style={{
|
||||
fontSize: "10px", color: "#4a9eff", cursor: "pointer",
|
||||
opacity: 0.7, flexShrink: 0, lineHeight: 1,
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget as HTMLElement).style.opacity = "1"}
|
||||
onMouseLeave={e => (e.currentTarget as HTMLElement).style.opacity = "0.7"}
|
||||
>
|
||||
🔗
|
||||
{quest.combat_indicators.map((ci, i) => (
|
||||
<span key={i} style={{ fontSize: "11px", color: "#94a3b8", whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{combatIcon(ci.combat_type)} x{ci.count}
|
||||
</span>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
{quest.combat_indicators.length > 0 && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "3px", marginTop: "3px" }}>
|
||||
{quest.combat_indicators.map((ci, i) => (
|
||||
<span key={i} style={{
|
||||
fontSize: "9px", padding: "1px 5px",
|
||||
background: "rgba(74,158,255,0.15)", color: "#4a9eff",
|
||||
border: "1px solid rgba(74,158,255,0.25)", borderRadius: "3px",
|
||||
}}>
|
||||
{ci.combat_type} {ci.count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{quest.note && (
|
||||
<div style={{ fontSize: "10px", color: "#94a3b8", marginTop: "2px", fontStyle: "italic" }}>
|
||||
→ {quest.note}
|
||||
<div style={{ fontSize: "11px", color: "#4a5568", marginTop: "2px", fontStyle: "italic" }}>
|
||||
→ <TextWithCoords text={quest.note} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -236,13 +374,12 @@ function QuestRow({ quest, completed, onToggle, indent }: {
|
||||
);
|
||||
}
|
||||
|
||||
function collectAllQuests(sections: import("../types").Section[] | undefined): string[] {
|
||||
if (!sections) return [];
|
||||
function collectAllQuests(sections: import("../types").Section[]): string[] {
|
||||
const names: string[] = [];
|
||||
for (const section of sections) {
|
||||
for (const item of section.items) {
|
||||
if (item.type === "Quest") names.push(item.name);
|
||||
else if (item.type === "Group") item.quests.forEach((q: import("../types").QuestItem) => names.push(q.name));
|
||||
else if (item.type === "Group") item.quests.forEach((q: QuestItem) => names.push(q.name));
|
||||
}
|
||||
}
|
||||
return names;
|
||||
|
||||
@ -4,19 +4,17 @@ export default function HomeView() {
|
||||
const { guides, openGuide, profiles, activeProfileId } = useStore();
|
||||
|
||||
const activeProfile = profiles.find(p => p.id === activeProfileId);
|
||||
|
||||
const totalQuests = guides.reduce((s, g) => s + g.total_quests, 0);
|
||||
const totalCompleted = guides.reduce((s, g) => s + g.completed_quests, 0);
|
||||
const globalPct = totalQuests > 0 ? Math.round((totalCompleted / totalQuests) * 100) : 0;
|
||||
|
||||
const completedGuides = guides.filter(g => g.total_quests > 0 && g.completed_quests === g.total_quests);
|
||||
const inProgressGuides = guides.filter(g => g.completed_quests > 0 && g.completed_quests < g.total_quests);
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "20px 24px", minHeight: 0 }}>
|
||||
<div className="with-scrollbar" style={{ flex: 1, overflowY: "auto", padding: "20px 24px", minHeight: 0 }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: "24px" }}>
|
||||
<h1 style={{ fontSize: "18px", fontWeight: 700, color: "#f0c040", marginBottom: "4px" }}>
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<h1 style={{ fontSize: "20px", fontWeight: 700, color: "#f0c040", marginBottom: "2px" }}>
|
||||
Tougli — Guide Dofus
|
||||
</h1>
|
||||
{activeProfile && (
|
||||
@ -32,7 +30,7 @@ export default function HomeView() {
|
||||
background: "#161b22", border: "1px solid #2d3748", borderRadius: "10px",
|
||||
padding: "16px 20px", marginBottom: "24px",
|
||||
}}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "8px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "8px" }}>
|
||||
<span style={{ fontSize: "13px", color: "#94a3b8" }}>Progression globale</span>
|
||||
<span style={{ fontSize: "13px", fontWeight: 700, color: "#f0c040" }}>
|
||||
{totalCompleted} / {totalQuests} quêtes ({globalPct}%)
|
||||
@ -45,7 +43,7 @@ export default function HomeView() {
|
||||
borderRadius: "3px", transition: "width 0.4s ease",
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ marginTop: "8px", display: "flex", gap: "16px" }}>
|
||||
<div style={{ marginTop: "10px", display: "flex", gap: "20px" }}>
|
||||
<Stat label="Complétés" value={completedGuides.length} color="#4ade80" />
|
||||
<Stat label="En cours" value={inProgressGuides.length} color="#f0c040" />
|
||||
<Stat label="Total" value={guides.length} color="#94a3b8" />
|
||||
@ -53,12 +51,12 @@ export default function HomeView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* In progress first */}
|
||||
{/* En cours */}
|
||||
{inProgressGuides.length > 0 && (
|
||||
<Section title="En cours" guides={inProgressGuides} onOpen={openGuide} />
|
||||
)}
|
||||
|
||||
{/* All guides grid */}
|
||||
{/* Tous les guides */}
|
||||
<Section title="Tous les guides" guides={guides} onOpen={openGuide} />
|
||||
</div>
|
||||
);
|
||||
@ -66,9 +64,9 @@ export default function HomeView() {
|
||||
|
||||
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
|
||||
<div>
|
||||
<span style={{ fontSize: "16px", fontWeight: 700, color }}>{value}</span>
|
||||
<span style={{ fontSize: "11px", color: "#4a5568" }}>{label}</span>
|
||||
<span style={{ fontSize: "11px", color: "#4a5568", marginLeft: "4px" }}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -80,13 +78,15 @@ function Section({ title, guides, onOpen }: {
|
||||
}) {
|
||||
return (
|
||||
<div style={{ marginBottom: "24px" }}>
|
||||
<h2 style={{ fontSize: "12px", fontWeight: 600, color: "#4a5568", textTransform: "uppercase", letterSpacing: "0.1em", marginBottom: "10px" }}>
|
||||
<h2 style={{
|
||||
fontSize: "11px", fontWeight: 600, color: "#4a5568",
|
||||
textTransform: "uppercase", letterSpacing: "0.1em",
|
||||
marginBottom: "10px", borderBottom: "1px solid #2d3748", paddingBottom: "4px",
|
||||
}}>
|
||||
{title}
|
||||
</h2>
|
||||
<div style={{
|
||||
display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: "8px",
|
||||
}}>
|
||||
{guides.map(guide => <GuideCard key={guide.gid} guide={guide} onOpen={onOpen} />)}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(175px, 1fr))", gap: "8px" }}>
|
||||
{guides.map(g => <GuideCard key={g.gid} guide={g} onOpen={onOpen} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -96,52 +96,63 @@ function GuideCard({ guide, onOpen }: {
|
||||
guide: import("../types").GuideListItem;
|
||||
onOpen: (gid: string) => void;
|
||||
}) {
|
||||
const pct = guide.total_quests > 0
|
||||
? Math.round((guide.completed_quests / guide.total_quests) * 100)
|
||||
: 0;
|
||||
const pct = guide.total_quests > 0 ? Math.round((guide.completed_quests / guide.total_quests) * 100) : 0;
|
||||
const isDone = pct === 100 && guide.total_quests > 0;
|
||||
const inProgress = guide.completed_quests > 0 && !isDone;
|
||||
|
||||
const accentColor = isDone ? "#4ade80" : inProgress ? "#f0c040" : "#4a9eff";
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onOpen(guide.gid)}
|
||||
style={{
|
||||
background: isDone ? "rgba(74,222,128,0.05)" : "#161b22",
|
||||
border: `1px solid ${isDone ? "rgba(74,222,128,0.3)" : "#2d3748"}`,
|
||||
background: "#161b22", border: `1px solid ${isDone ? "rgba(74,222,128,0.25)" : "#2d3748"}`,
|
||||
borderRadius: "8px", padding: "12px 14px", cursor: "pointer",
|
||||
textAlign: "left", transition: "all 0.15s",
|
||||
textAlign: "left", transition: "all 0.15s", position: "relative", overflow: "hidden",
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor = isDone ? "rgba(74,222,128,0.6)" : "#f0c040";
|
||||
(e.currentTarget as HTMLElement).style.background = isDone ? "rgba(74,222,128,0.08)" : "#1a2233";
|
||||
(e.currentTarget as HTMLElement).style.borderColor = accentColor;
|
||||
(e.currentTarget as HTMLElement).style.background = "#1a2233";
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor = isDone ? "rgba(74,222,128,0.3)" : "#2d3748";
|
||||
(e.currentTarget as HTMLElement).style.background = isDone ? "rgba(74,222,128,0.05)" : "#161b22";
|
||||
(e.currentTarget as HTMLElement).style.borderColor = isDone ? "rgba(74,222,128,0.25)" : "#2d3748";
|
||||
(e.currentTarget as HTMLElement).style.background = "#161b22";
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "8px" }}>
|
||||
<span style={{
|
||||
fontSize: "12px", fontWeight: 600, color: isDone ? "#4ade80" : "#e2e8f0",
|
||||
lineHeight: 1.3,
|
||||
}}>
|
||||
{guide.name}
|
||||
</span>
|
||||
{isDone && <span style={{ fontSize: "14px" }}>✓</span>}
|
||||
</div>
|
||||
<div style={{ height: "3px", background: "#2d3748", borderRadius: "2px", overflow: "hidden", marginBottom: "6px" }}>
|
||||
<div style={{
|
||||
height: "100%", width: `${pct}%`,
|
||||
background: isDone ? "#4ade80" : pct > 60 ? "#f0c040" : "#4a9eff",
|
||||
borderRadius: "2px",
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<span style={{ fontSize: "10px", color: "#4a5568" }}>
|
||||
{guide.completed_quests}/{guide.total_quests} quêtes
|
||||
</span>
|
||||
<span style={{ fontSize: "10px", fontWeight: 700, color: isDone ? "#4ade80" : "#94a3b8" }}>
|
||||
{pct}%
|
||||
</span>
|
||||
{/* Indicateur latéral */}
|
||||
<div style={{
|
||||
position: "absolute", left: 0, top: 0, bottom: 0, width: "3px",
|
||||
background: accentColor, opacity: isDone ? 1 : inProgress ? 0.8 : 0.3,
|
||||
borderRadius: "8px 0 0 8px",
|
||||
}} />
|
||||
|
||||
<div style={{ paddingLeft: "4px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "8px" }}>
|
||||
<span style={{
|
||||
fontSize: "12px", fontWeight: 600, lineHeight: 1.3,
|
||||
color: isDone ? "#4ade80" : "#e2e8f0",
|
||||
}}>
|
||||
{guide.name}
|
||||
</span>
|
||||
{isDone && <span style={{ fontSize: "12px", flexShrink: 0 }}>✓</span>}
|
||||
</div>
|
||||
|
||||
<div style={{ height: "3px", background: "#2d3748", borderRadius: "2px", overflow: "hidden", marginBottom: "6px" }}>
|
||||
<div style={{
|
||||
height: "100%", width: `${pct}%`,
|
||||
background: accentColor,
|
||||
borderRadius: "2px", transition: "width 0.3s ease",
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontSize: "10px", color: "#4a5568" }}>
|
||||
{guide.completed_quests}/{guide.total_quests} quêtes
|
||||
</span>
|
||||
<span style={{ fontSize: "10px", fontWeight: 700, color: accentColor }}>
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
74
src/components/ImageViewerWindow.tsx
Normal file
74
src/components/ImageViewerWindow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
298
src/components/QuestDetailPanel.tsx
Normal file
298
src/components/QuestDetailPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/components/ResizeHandles.tsx
Normal file
40
src/components/ResizeHandles.tsx
Normal 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 }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
204
src/components/SettingsPanel.tsx
Normal file
204
src/components/SettingsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,22 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useStore } from "../store";
|
||||
|
||||
function useWindowWidth() {
|
||||
const [width, setWidth] = useState(window.innerWidth);
|
||||
useEffect(() => {
|
||||
const handler = () => setWidth(window.innerWidth);
|
||||
window.addEventListener("resize", handler);
|
||||
return () => window.removeEventListener("resize", handler);
|
||||
}, []);
|
||||
return width;
|
||||
}
|
||||
|
||||
export default function Sidebar() {
|
||||
const { guides, openGuide, activeGuideGid, view } = useStore();
|
||||
const { guides, openGuide, activeGuideGid, view, sidebarCollapsed, setSidebarCollapsed } = useStore();
|
||||
const [search, setSearch] = useState("");
|
||||
const collapsed = sidebarCollapsed;
|
||||
const windowWidth = useWindowWidth();
|
||||
const isOverlay = collapsed || windowWidth < 500;
|
||||
|
||||
const filtered = guides.filter(g =>
|
||||
g.name.toLowerCase().includes(search.toLowerCase())
|
||||
@ -11,84 +24,140 @@ export default function Sidebar() {
|
||||
|
||||
return (
|
||||
<aside style={{
|
||||
width: "220px", flexShrink: 0,
|
||||
background: "#161b22", borderRight: "1px solid #2d3748",
|
||||
display: "flex", flexDirection: "column", overflow: "hidden",
|
||||
position: isOverlay ? "absolute" : "relative",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
zIndex: 10,
|
||||
width: collapsed ? "36px" : "190px",
|
||||
flexShrink: 0,
|
||||
background: collapsed ? "transparent" : "#161b22",
|
||||
borderRight: collapsed ? "none" : "1px solid #2d3748",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
transition: "width 0.2s ease, background 0.2s ease",
|
||||
}}>
|
||||
<div style={{ padding: "10px 12px", borderBottom: "1px solid #2d3748" }}>
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Rechercher un Dofus…"
|
||||
style={{
|
||||
width: "100%", background: "#0d1117", border: "1px solid #2d3748",
|
||||
borderRadius: "6px", padding: "6px 10px", color: "#e2e8f0",
|
||||
fontSize: "12px", outline: "none",
|
||||
}}
|
||||
onFocus={e => (e.target.style.borderColor = "#f0c040")}
|
||||
onBlur={e => (e.target.style.borderColor = "#2d3748")}
|
||||
/>
|
||||
</div>
|
||||
{/* 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>
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "8px 0", scrollbarWidth: "none" }}>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ padding: "16px 12px", color: "#4a5568", fontSize: "12px", textAlign: "center" }}>
|
||||
Aucun guide synchronisé
|
||||
{!collapsed && (
|
||||
<>
|
||||
{/* Search */}
|
||||
<div style={{ padding: "10px 12px", borderBottom: "1px solid #2d3748" }}>
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Rechercher un Dofus…"
|
||||
style={{
|
||||
width: "100%", background: "#0d1117", border: "1px solid #2d3748",
|
||||
borderRadius: "6px", padding: "6px 10px", color: "#e2e8f0",
|
||||
fontSize: "12px", outline: "none", boxSizing: "border-box",
|
||||
}}
|
||||
onFocus={e => (e.target.style.borderColor = "#f0c040")}
|
||||
onBlur={e => (e.target.style.borderColor = "#2d3748")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(guide => {
|
||||
const pct = guide.total_quests > 0
|
||||
? Math.round((guide.completed_quests / guide.total_quests) * 100)
|
||||
: 0;
|
||||
const isActive = guide.gid === activeGuideGid && view === "guide";
|
||||
|
||||
return (
|
||||
<button
|
||||
key={guide.gid}
|
||||
onClick={() => openGuide(guide.gid)}
|
||||
style={{
|
||||
width: "100%", textAlign: "left", background: isActive ? "rgba(240,192,64,0.08)" : "transparent",
|
||||
border: "none", borderLeft: isActive ? "2px solid #f0c040" : "2px solid transparent",
|
||||
padding: "8px 12px", cursor: "pointer", transition: "all 0.12s",
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
if (!isActive) (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.03)";
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{
|
||||
fontSize: "12px", fontWeight: isActive ? 600 : 400,
|
||||
color: isActive ? "#f0c040" : "#e2e8f0",
|
||||
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
||||
maxWidth: "140px",
|
||||
}}>
|
||||
{guide.name}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: "10px", color: pct === 100 ? "#4ade80" : "#94a3b8",
|
||||
fontWeight: 600, flexShrink: 0,
|
||||
}}>
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: "4px", height: "2px", background: "#2d3748",
|
||||
borderRadius: "1px", overflow: "hidden",
|
||||
}}>
|
||||
<div style={{
|
||||
height: "100%", width: `${pct}%`,
|
||||
background: pct === 100 ? "#4ade80" : pct > 50 ? "#f0c040" : "#4a9eff",
|
||||
transition: "width 0.3s ease",
|
||||
}} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{/* Guide list */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "8px 0", scrollbarWidth: "none" }}>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ padding: "16px 12px", color: "#4a5568", fontSize: "12px", textAlign: "center" }}>
|
||||
Aucun guide synchronisé
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(guide => {
|
||||
const pct = guide.total_quests > 0
|
||||
? Math.round((guide.completed_quests / guide.total_quests) * 100)
|
||||
: 0;
|
||||
const isActive = guide.gid === activeGuideGid && view === "guide";
|
||||
|
||||
return (
|
||||
<button
|
||||
key={guide.gid}
|
||||
onClick={() => openGuide(guide.gid)}
|
||||
style={{
|
||||
width: "100%", textAlign: "left",
|
||||
background: isActive ? "rgba(240,192,64,0.08)" : "transparent",
|
||||
border: "none", borderLeft: isActive ? "2px solid #f0c040" : "2px solid transparent",
|
||||
padding: "8px 12px", cursor: "pointer", transition: "all 0.12s",
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
if (!isActive) (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.03)";
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "4px" }}>
|
||||
<span style={{
|
||||
fontSize: "12px", fontWeight: isActive ? 600 : 400,
|
||||
color: isActive ? "#f0c040" : "#e2e8f0",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
wordBreak: "break-word",
|
||||
lineHeight: 1.3,
|
||||
flex: 1,
|
||||
} as React.CSSProperties}>
|
||||
{guide.name}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: "10px", color: pct === 100 ? "#4ade80" : "#94a3b8",
|
||||
fontWeight: 600, flexShrink: 0,
|
||||
}}>
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: "4px", height: "2px", background: "#2d3748",
|
||||
borderRadius: "1px", overflow: "hidden",
|
||||
}}>
|
||||
<div style={{
|
||||
height: "100%", width: `${pct}%`,
|
||||
background: pct === 100 ? "#4ade80" : pct > 50 ? "#f0c040" : "#4a9eff",
|
||||
transition: "width 0.3s ease",
|
||||
}} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
53
src/components/TextWithCoords.tsx
Normal file
53
src/components/TextWithCoords.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,20 +1,37 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { useStore } from "../store";
|
||||
|
||||
interface Props {
|
||||
onOpenProfiles: () => void;
|
||||
function useWindowWidth() {
|
||||
const [width, setWidth] = useState(window.innerWidth);
|
||||
useEffect(() => {
|
||||
const handler = () => setWidth(window.innerWidth);
|
||||
window.addEventListener("resize", handler);
|
||||
return () => window.removeEventListener("resize", handler);
|
||||
}, []);
|
||||
return width;
|
||||
}
|
||||
|
||||
export default function TitleBar({ onOpenProfiles }: Props) {
|
||||
const { alwaysOnTop, toggleAlwaysOnTop, syncing, syncGuides, view, closeGuide, activeGuideData } = useStore();
|
||||
interface Props {
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
export default function TitleBar({ onOpenSettings }: Props) {
|
||||
const { view, closeGuide, activeGuideData } = useStore();
|
||||
const windowWidth = useWindowWidth();
|
||||
|
||||
function handleDragMouseDown(e: React.MouseEvent) {
|
||||
if (e.button === 0 && !alwaysOnTop) {
|
||||
if (e.button === 0) {
|
||||
getCurrentWindow().startDragging();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClose() {
|
||||
const viewer = await WebviewWindow.getByLabel("image-viewer");
|
||||
if (viewer) {
|
||||
try { await viewer.close(); } catch (_) {}
|
||||
}
|
||||
await getCurrentWindow().close();
|
||||
}
|
||||
|
||||
@ -36,14 +53,14 @@ export default function TitleBar({ onOpenProfiles }: Props) {
|
||||
onMouseDown={handleDragMouseDown}
|
||||
style={{
|
||||
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" }} />
|
||||
<span style={{ pointerEvents: "none", fontSize: "13px", fontWeight: 700, color: "#f0c040", letterSpacing: "0.05em" }}>
|
||||
TougliGui
|
||||
</span>
|
||||
{view === "guide" && activeGuideData && (
|
||||
{view === "guide" && activeGuideData && windowWidth >= 400 && (
|
||||
<>
|
||||
<span style={{ pointerEvents: "none", color: "#4a5568", fontSize: "12px" }}>›</span>
|
||||
<span style={{ pointerEvents: "none", fontSize: "12px", color: "#94a3b8" }}>{activeGuideData.name}</span>
|
||||
@ -59,24 +76,8 @@ export default function TitleBar({ onOpenProfiles }: Props) {
|
||||
</TitleButton>
|
||||
)}
|
||||
|
||||
<TitleButton onClick={onOpenProfiles} title="Gérer les profils">
|
||||
👤
|
||||
</TitleButton>
|
||||
|
||||
<TitleButton
|
||||
onClick={syncGuides}
|
||||
title="Synchroniser avec Google Sheets"
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing ? <SpinIcon /> : "↻"}
|
||||
</TitleButton>
|
||||
|
||||
<TitleButton
|
||||
onClick={toggleAlwaysOnTop}
|
||||
title={alwaysOnTop ? "Désactiver fenêtre flottante" : "Activer fenêtre flottante"}
|
||||
active={alwaysOnTop}
|
||||
>
|
||||
📌
|
||||
<TitleButton onClick={onOpenSettings} title="Paramètres">
|
||||
⚙
|
||||
</TitleButton>
|
||||
|
||||
<div style={{ width: "1px", height: "16px", background: "#2d3748", margin: "0 4px" }} />
|
||||
@ -134,8 +135,3 @@ function TitleButton({
|
||||
);
|
||||
}
|
||||
|
||||
function SpinIcon() {
|
||||
return (
|
||||
<span style={{ display: "inline-block", animation: "spin 1s linear infinite" }}>↻</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -40,14 +40,13 @@ html, body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar,
|
||||
body::-webkit-scrollbar {
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
* {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
@ -65,20 +64,25 @@ body {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
/* Scrollbar explicite sur les zones qui en ont besoin */
|
||||
.with-scrollbar {
|
||||
scrollbar-width: thin !important;
|
||||
scrollbar-color: var(--color-border-bright) var(--color-bg-deep) !important;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-deep);
|
||||
.with-scrollbar::-webkit-scrollbar {
|
||||
display: block !important;
|
||||
width: 4px !important;
|
||||
height: 4px !important;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-bright);
|
||||
border-radius: 3px;
|
||||
.with-scrollbar::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-deep) !important;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-gold-dim);
|
||||
.with-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-bright) !important;
|
||||
border-radius: 2px !important;
|
||||
}
|
||||
.with-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-gold-dim) !important;
|
||||
}
|
||||
|
||||
/* Checkbox custom */
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import ImageViewerWindow from "./components/ImageViewerWindow";
|
||||
|
||||
// Block all document-level scrolling
|
||||
document.addEventListener("wheel", (e) => {
|
||||
@ -17,8 +18,10 @@ document.addEventListener("scroll", () => {
|
||||
window.scrollTo(0, 0);
|
||||
}, { passive: true });
|
||||
|
||||
const isViewer = new URLSearchParams(window.location.search).get("viewer") === "1";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
{isViewer ? <ImageViewerWindow /> : <App />}
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
44
src/store.ts
44
src/store.ts
@ -9,11 +9,16 @@ interface AppState {
|
||||
activeGuideGid: string | null;
|
||||
activeGuideData: GuideData | null;
|
||||
completedQuests: Set<string>;
|
||||
alwaysOnTop: boolean;
|
||||
syncing: boolean;
|
||||
syncProgress: { current: number; total: number; label: string } | null;
|
||||
view: "home" | "guide";
|
||||
sidebarCollapsed: boolean;
|
||||
resourcesPanelCollapsed: boolean;
|
||||
resourceInventory: Record<string, number>;
|
||||
|
||||
setResourcesPanelCollapsed: (v: boolean) => void;
|
||||
loadResourceInventory: () => Promise<void>;
|
||||
setResourceQuantity: (name: string, qty: number) => Promise<void>;
|
||||
loadProfiles: () => Promise<void>;
|
||||
setActiveProfile: (id: string) => Promise<void>;
|
||||
createProfile: (name: string) => Promise<void>;
|
||||
@ -21,13 +26,13 @@ interface AppState {
|
||||
|
||||
loadGuides: () => Promise<void>;
|
||||
openGuide: (gid: string) => Promise<void>;
|
||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||
closeGuide: () => void;
|
||||
|
||||
toggleQuest: (questName: string) => Promise<void>;
|
||||
|
||||
syncGuides: () => Promise<SyncResult>;
|
||||
syncSingleGuide: (gid: string, name: string) => Promise<void>;
|
||||
toggleAlwaysOnTop: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useStore = create<AppState>((set, get) => ({
|
||||
@ -37,10 +42,30 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
activeGuideGid: null,
|
||||
activeGuideData: null,
|
||||
completedQuests: new Set(),
|
||||
alwaysOnTop: true,
|
||||
syncing: false,
|
||||
syncProgress: null,
|
||||
view: "home",
|
||||
sidebarCollapsed: false,
|
||||
resourcesPanelCollapsed: false,
|
||||
resourceInventory: {},
|
||||
|
||||
setResourcesPanelCollapsed: (v) => set({ resourcesPanelCollapsed: v }),
|
||||
|
||||
loadResourceInventory: async () => {
|
||||
const { activeProfileId } = get();
|
||||
if (!activeProfileId) return;
|
||||
const rows = await invoke<[string, number][]>("get_resource_inventory", { profileId: activeProfileId });
|
||||
const inventory: Record<string, number> = {};
|
||||
for (const [name, qty] of rows) inventory[name] = qty;
|
||||
set({ resourceInventory: inventory });
|
||||
},
|
||||
|
||||
setResourceQuantity: async (name, qty) => {
|
||||
const { activeProfileId } = get();
|
||||
if (!activeProfileId) return;
|
||||
set(state => ({ resourceInventory: { ...state.resourceInventory, [name]: qty } }));
|
||||
await invoke("set_resource_quantity", { profileId: activeProfileId, resourceName: name, quantity: qty });
|
||||
},
|
||||
|
||||
loadProfiles: async () => {
|
||||
const profiles = await invoke<Profile[]>("get_profiles");
|
||||
@ -50,6 +75,7 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
if (activeId) {
|
||||
const completed = await invoke<string[]>("get_completed_quests", { profileId: activeId });
|
||||
set({ completedQuests: new Set(completed) });
|
||||
await get().loadResourceInventory();
|
||||
}
|
||||
},
|
||||
|
||||
@ -57,7 +83,7 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
await invoke("set_setting", { key: "active_profile", value: id });
|
||||
const completed = await invoke<string[]>("get_completed_quests", { profileId: id });
|
||||
set({ activeProfileId: id, completedQuests: new Set(completed) });
|
||||
await get().loadGuides();
|
||||
await Promise.all([get().loadGuides(), get().loadResourceInventory()]);
|
||||
},
|
||||
|
||||
createProfile: async (name) => {
|
||||
@ -84,10 +110,14 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
|
||||
openGuide: async (gid) => {
|
||||
const data = await invoke<GuideData>("get_guide", { gid });
|
||||
await invoke("set_setting", { key: "active_guide", value: gid });
|
||||
set({ activeGuideGid: gid, activeGuideData: data, view: "guide" });
|
||||
},
|
||||
|
||||
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
|
||||
|
||||
closeGuide: () => {
|
||||
invoke("set_setting", { key: "active_guide", value: "" });
|
||||
set({ activeGuideGid: null, activeGuideData: null, view: "home" });
|
||||
},
|
||||
|
||||
@ -133,10 +163,4 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
syncSingleGuide: async (gid, name) => {
|
||||
await invoke("sync_single_guide", { gid, name });
|
||||
},
|
||||
|
||||
toggleAlwaysOnTop: async () => {
|
||||
const next = !get().alwaysOnTop;
|
||||
await invoke("set_always_on_top", { value: next });
|
||||
set({ alwaysOnTop: next });
|
||||
},
|
||||
}));
|
||||
|
||||
@ -68,3 +68,10 @@ export interface SyncResult {
|
||||
synced: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface QuestStep {
|
||||
index: number;
|
||||
text: string;
|
||||
images: string[];
|
||||
launch_position: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user