This commit is contained in:
parent
db4b99aee3
commit
1dee8b528f
30 changed files with 3491 additions and 2444 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
|
website/dist/
|
||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
|
||||||
59
README.md
59
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Sokko G
|
# Sokko G
|
||||||
|
|
||||||
Webapp locale pour préparer et accompagner des sessions gaming sur second écran.
|
Webapp React locale pour préparer et accompagner des sessions gaming sur second écran.
|
||||||
|
|
||||||
Sokko G propose des toolboxes modulaires stockées dans le navigateur, ainsi que des pages de guides de jeu maintenues dans le dépôt. Aucune donnée utilisateur n’est envoyée côté serveur.
|
Sokko G propose des toolboxes modulaires stockées dans le navigateur, ainsi que des pages de guides de jeu maintenues dans le dépôt. Aucune donnée utilisateur n’est envoyée côté serveur.
|
||||||
|
|
||||||
|
|
@ -23,6 +23,15 @@ npm run dev
|
||||||
|
|
||||||
Par défaut, le site démarre sur `http://localhost:5173`.
|
Par défaut, le site démarre sur `http://localhost:5173`.
|
||||||
|
|
||||||
|
Pour générer la version statique de production :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Le build est généré dans `website/dist`.
|
||||||
|
`npm start` sert cette version statique avec `server.mjs`.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Le serveur lit automatiquement un fichier `.env` à la racine du projet.
|
Le serveur lit automatiquement un fichier `.env` à la racine du projet.
|
||||||
|
|
@ -47,7 +56,7 @@ Avant un push ou une mise en ligne :
|
||||||
npm run check
|
npm run check
|
||||||
```
|
```
|
||||||
|
|
||||||
Cette commande vérifie la syntaxe de `website/src/app.js`, celle de `server.mjs`, puis lance les tests.
|
Cette commande vérifie la configuration Node/Vite, lance les tests, puis exécute le build React.
|
||||||
|
|
||||||
## Contenu éditable
|
## Contenu éditable
|
||||||
|
|
||||||
|
|
@ -65,6 +74,35 @@ Les contenus maintenus à la main sont regroupés dans `website/public/data`.
|
||||||
|
|
||||||
Les images publiques sont dans `website/public/static`.
|
Les images publiques sont dans `website/public/static`.
|
||||||
|
|
||||||
|
## Pages jeux
|
||||||
|
|
||||||
|
Les pages jeux sont dans `website/src/features/games`.
|
||||||
|
|
||||||
|
- `website/src/features/games/GamesPage.jsx` : liste des jeux disponibles.
|
||||||
|
- `website/src/features/games/GameRoute.jsx` : route vers la page du jeu demandé.
|
||||||
|
- `website/src/features/games/mhwilds/` : vues et composants propres à Monster Hunter Wilds.
|
||||||
|
|
||||||
|
Pour ajouter un jeu :
|
||||||
|
|
||||||
|
- ajouter son entrée dans `website/public/data/games.json` ;
|
||||||
|
- créer son dossier dans `website/src/features/games/` si la page nécessite un rendu spécifique ;
|
||||||
|
- brancher sa route dans `website/src/features/games/GameRoute.jsx`.
|
||||||
|
|
||||||
|
## Outils de toolbox
|
||||||
|
|
||||||
|
Les outils de toolbox sont déclarés dans `website/src/features/toolboxes/modules/index.jsx`.
|
||||||
|
Chaque outil possède son propre fichier de composant :
|
||||||
|
|
||||||
|
- `website/src/features/toolboxes/modules/NotepadModule.jsx`
|
||||||
|
- `website/src/features/toolboxes/modules/ChecklistModule.jsx`
|
||||||
|
- `website/src/features/toolboxes/modules/ScreenshotsModule.jsx`
|
||||||
|
|
||||||
|
Pour ajouter ou maintenir un outil :
|
||||||
|
|
||||||
|
- créer son fichier dans `website/src/features/toolboxes/modules/` ;
|
||||||
|
- l’ajouter au registre `MODULE_COMPONENTS` dans `website/src/features/toolboxes/modules/index.jsx` ;
|
||||||
|
- garder les données persistées via les helpers de `website/src/main.jsx` tant que le stockage reste en localStorage.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -72,15 +110,24 @@ Les images publiques sont dans `website/public/static`.
|
||||||
├── DESIGN_SYSTEM.md
|
├── DESIGN_SYSTEM.md
|
||||||
├── package.json
|
├── package.json
|
||||||
├── server.mjs
|
├── server.mjs
|
||||||
|
├── vite.config.js
|
||||||
├── tests/
|
├── tests/
|
||||||
└── website/
|
└── website/
|
||||||
|
├── index.html
|
||||||
├── public/
|
├── public/
|
||||||
│ ├── data/
|
│ ├── data/
|
||||||
│ ├── static/
|
│ └── static/
|
||||||
│ └── index.html
|
|
||||||
└── src/
|
└── src/
|
||||||
├── app.js
|
├── main.jsx
|
||||||
└── styles.css
|
├── components/
|
||||||
|
├── features/
|
||||||
|
│ ├── games/
|
||||||
|
│ │ └── mhwilds/
|
||||||
|
│ └── toolboxes/
|
||||||
|
│ └── modules/
|
||||||
|
└── styles/
|
||||||
|
├── _tokens.scss
|
||||||
|
└── main.scss
|
||||||
```
|
```
|
||||||
|
|
||||||
## Stockage local
|
## Stockage local
|
||||||
|
|
|
||||||
1295
package-lock.json
generated
Normal file
1295
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
15
package.json
15
package.json
|
|
@ -5,8 +5,19 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.mjs",
|
"start": "node server.mjs",
|
||||||
"dev": "node server.mjs",
|
"dev": "vite",
|
||||||
"check": "node --check website/src/app.js && node --check server.mjs && npm test",
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "node --check server.mjs && node --check vite.config.js && npm test && npm run build",
|
||||||
"test": "node --test"
|
"test": "node --test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
|
"vite": "^8.1.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"sass": "^1.101.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,11 @@ import { createServer } from "node:http";
|
||||||
import { extname, join, relative, resolve } from "node:path";
|
import { extname, join, relative, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const root = resolve(fileURLToPath(new URL(".", import.meta.url)), "website");
|
const projectRoot = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
||||||
const publicRoot = join(root, "public");
|
const websiteRoot = join(projectRoot, "website");
|
||||||
|
const distRoot = join(websiteRoot, "dist");
|
||||||
|
const root = existsSync(distRoot) ? distRoot : websiteRoot;
|
||||||
|
const publicRoot = existsSync(distRoot) ? distRoot : join(websiteRoot, "public");
|
||||||
|
|
||||||
function loadEnvFile(file = ".env") {
|
function loadEnvFile(file = ".env") {
|
||||||
if (!existsSync(file)) return;
|
if (!existsSync(file)) return;
|
||||||
|
|
@ -63,7 +66,7 @@ function resolvePath(url) {
|
||||||
const publicCandidate = fileIfReadable(resolve(publicRoot, `.${requested}`));
|
const publicCandidate = fileIfReadable(resolve(publicRoot, `.${requested}`));
|
||||||
if (publicCandidate && isInsideRoot(publicRoot, publicCandidate)) return publicCandidate;
|
if (publicCandidate && isInsideRoot(publicRoot, publicCandidate)) return publicCandidate;
|
||||||
|
|
||||||
return join(publicRoot, "index.html");
|
return join(root, "index.html");
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = createServer((req, res) => {
|
const server = createServer((req, res) => {
|
||||||
|
|
|
||||||
|
|
@ -2,59 +2,85 @@ import { readFile } from "node:fs/promises";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
test("static entrypoint loads the application assets", async () => {
|
test("vite entrypoint loads the react application", async () => {
|
||||||
const html = await readFile("website/public/index.html", "utf8");
|
const html = await readFile("website/index.html", "utf8");
|
||||||
|
|
||||||
assert.match(html, /<div id="app"><\/div>/);
|
assert.match(html, /<div id="app"><\/div>/);
|
||||||
assert.match(html, /\/favicon\.ico/);
|
assert.match(html, /\/favicon\.ico/);
|
||||||
assert.match(html, /\/src\/app\.js/);
|
assert.match(html, /\/src\/main\.jsx/);
|
||||||
assert.match(html, /\/src\/styles\.css/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("application defines the expected local toolbox primitives", async () => {
|
test("react application defines the expected local toolbox primitives", async () => {
|
||||||
const source = await readFile("website/src/app.js", "utf8");
|
const source = await readFile("website/src/main.jsx", "utf8");
|
||||||
|
const styles = await readFile("website/src/styles/main.scss", "utf8");
|
||||||
|
const styleTokens = await readFile("website/src/styles/_tokens.scss", "utf8");
|
||||||
|
const iconComponent = await readFile("website/src/components/Icon.jsx", "utf8");
|
||||||
|
const gamesPage = await readFile("website/src/features/games/GamesPage.jsx", "utf8");
|
||||||
|
const gameRoute = await readFile("website/src/features/games/GameRoute.jsx", "utf8");
|
||||||
|
const mhwildsPage = await readFile("website/src/features/games/mhwilds/MhwildsPage.jsx", "utf8");
|
||||||
|
const mhwildsListing = await readFile("website/src/features/games/mhwilds/MhwildsListing.jsx", "utf8");
|
||||||
|
const mhwildsFilters = await readFile("website/src/features/games/mhwilds/MhwildsFilters.jsx", "utf8");
|
||||||
|
const monsterCard = await readFile("website/src/features/games/mhwilds/cards/MonsterCard.jsx", "utf8");
|
||||||
|
const endemicCard = await readFile("website/src/features/games/mhwilds/cards/EndemicCard.jsx", "utf8");
|
||||||
|
const damageTable = await readFile("website/src/features/games/mhwilds/cards/DamageTable.jsx", "utf8");
|
||||||
|
const moduleRegistry = await readFile("website/src/features/toolboxes/modules/index.jsx", "utf8");
|
||||||
|
const notepadModule = await readFile("website/src/features/toolboxes/modules/NotepadModule.jsx", "utf8");
|
||||||
|
const checklistModule = await readFile("website/src/features/toolboxes/modules/ChecklistModule.jsx", "utf8");
|
||||||
|
const screenshotsModule = await readFile("website/src/features/toolboxes/modules/ScreenshotsModule.jsx", "utf8");
|
||||||
|
|
||||||
assert.match(source, /sokkog:toolboxes/);
|
assert.match(source, /sokkog:toolboxes/);
|
||||||
assert.match(source, /sokkog:game-toolbox-links/);
|
assert.match(source, /sokkog:game-toolbox-links/);
|
||||||
|
assert.match(source, /styles\/main\.scss/);
|
||||||
|
assert.match(styles, /@use "tokens"/);
|
||||||
|
assert.match(styleTokens, /--gradient-brand/);
|
||||||
|
assert.match(source, /features\/toolboxes\/modules\/index\.jsx/);
|
||||||
|
assert.match(source, /features\/games\/GamesPage\.jsx/);
|
||||||
|
assert.match(source, /features\/games\/GameRoute\.jsx/);
|
||||||
|
assert.match(iconComponent, /export function Icon/);
|
||||||
|
assert.match(gamesPage, /export function GamesPage/);
|
||||||
|
assert.match(gameRoute, /export function GameRoute/);
|
||||||
|
assert.match(gameRoute, /MhwildsPage/);
|
||||||
|
assert.match(mhwildsPage, /export function MhwildsPage/);
|
||||||
|
assert.match(mhwildsListing, /export function MhwildsListing/);
|
||||||
|
assert.match(mhwildsFilters, /export function MhwildsFilters/);
|
||||||
|
assert.match(monsterCard, /export function MonsterCard/);
|
||||||
|
assert.match(endemicCard, /export function EndemicCard/);
|
||||||
|
assert.match(damageTable, /rowIndex/);
|
||||||
|
assert.match(moduleRegistry, /export const TOOLBOX_MODULES/);
|
||||||
|
assert.match(moduleRegistry, /export function ToolboxModules/);
|
||||||
|
assert.match(moduleRegistry, /MODULE_COMPONENTS/);
|
||||||
|
assert.match(moduleRegistry, /notepad:/);
|
||||||
|
assert.match(moduleRegistry, /checklist:/);
|
||||||
|
assert.match(moduleRegistry, /screenshots:/);
|
||||||
|
assert.match(notepadModule, /export function NotepadModule/);
|
||||||
|
assert.match(checklistModule, /export function ChecklistModule/);
|
||||||
|
assert.match(screenshotsModule, /export function ScreenshotsModule/);
|
||||||
assert.match(source, /DEFAULT_SITE_CONTENT/);
|
assert.match(source, /DEFAULT_SITE_CONTENT/);
|
||||||
assert.match(source, /function loadSiteContent/);
|
|
||||||
assert.match(source, /\/data\/site\.json/);
|
assert.match(source, /\/data\/site\.json/);
|
||||||
assert.match(source, /APP_STORAGE_LIMIT_BYTES/);
|
assert.match(source, /APP_STORAGE_LIMIT_BYTES/);
|
||||||
assert.match(source, /function renderStorageQuota/);
|
assert.match(source, /function StorageQuota/);
|
||||||
assert.match(source, /function writeStorageValue/);
|
assert.match(source, /function writeStorageValue/);
|
||||||
assert.match(source, /role="progressbar"/);
|
assert.match(source, /role="progressbar"/);
|
||||||
assert.match(source, /export-all-toolboxes/);
|
|
||||||
assert.match(source, /import-all-toolboxes/);
|
|
||||||
assert.match(source, /function createGlobalExportPayload/);
|
assert.match(source, /function createGlobalExportPayload/);
|
||||||
assert.match(source, /function importAllToolboxes/);
|
assert.match(source, /async function importAllToolboxes/);
|
||||||
assert.match(source, /open-link-toolbox-modal/);
|
assert.match(source, /function LinkToolboxModal/);
|
||||||
assert.match(source, /function showToolboxLinkModal/);
|
|
||||||
assert.match(source, /data-game-id/);
|
|
||||||
assert.match(source, /notepad/);
|
|
||||||
assert.match(source, /checklist/);
|
|
||||||
assert.match(source, /screenshots/);
|
|
||||||
assert.doesNotMatch(source, /description: _description/);
|
assert.doesNotMatch(source, /description: _description/);
|
||||||
assert.match(source, /function showToolboxCreateModal/);
|
assert.match(source, /function CreateToolboxModal/);
|
||||||
assert.match(source, /data-action="toolbox-create-form"/);
|
assert.match(source, /const getToolboxGameId/);
|
||||||
assert.match(source, /function getToolboxGameId/);
|
|
||||||
assert.match(source, /dragon\.png/);
|
assert.match(source, /dragon\.png/);
|
||||||
assert.match(source, /const game = getToolboxGame\(toolbox\)/);
|
assert.match(source, /getToolboxGame\(toolbox\)/);
|
||||||
assert.match(source, /moduleColumns/);
|
assert.match(source, /moduleColumns/);
|
||||||
assert.match(source, /data-action="set-module-layout"/);
|
assert.match(source, /function ToolboxView/);
|
||||||
assert.match(source, /data-action="edit-toolbox-title"/);
|
assert.match(moduleRegistry, /onDragStart/);
|
||||||
assert.match(source, /function renderToolboxModules/);
|
|
||||||
assert.match(source, /function moveToolboxModule/);
|
|
||||||
assert.match(source, /dragstart/);
|
|
||||||
assert.match(source, /qtyTarget/);
|
assert.match(source, /qtyTarget/);
|
||||||
assert.match(source, /qtyCurrent/);
|
assert.match(source, /qtyCurrent/);
|
||||||
assert.match(source, /adjust-check-qty/);
|
assert.match(checklistModule, /ChecklistItem/);
|
||||||
assert.match(source, /paste-screenshot/);
|
assert.match(screenshotsModule, /clipboardData/);
|
||||||
assert.match(source, /clipboardData/);
|
assert.match(source, /async function addScreenshotFiles/);
|
||||||
assert.match(source, /function addScreenshotFiles/);
|
assert.match(source, /function ScreenshotViewer/);
|
||||||
assert.match(source, /view-screenshot/);
|
assert.match(source, /function openImageInNewTab/);
|
||||||
assert.match(source, /function showScreenshotViewer/);
|
assert.match(source, /<Icon name="zoom"/);
|
||||||
assert.match(source, /drop-screenshot/);
|
assert.match(screenshotsModule, /dataTransfer\.files/);
|
||||||
assert.match(source, /dataTransfer\.files/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("mhwilds data and assets are available", async () => {
|
test("mhwilds data and assets are available", async () => {
|
||||||
|
|
@ -63,7 +89,7 @@ test("mhwilds data and assets are available", async () => {
|
||||||
const monsters = JSON.parse(await readFile("website/public/data/mhwilds/monsters.json", "utf8"));
|
const monsters = JSON.parse(await readFile("website/public/data/mhwilds/monsters.json", "utf8"));
|
||||||
const endemicLife = JSON.parse(await readFile("website/public/data/mhwilds/endemic_life.json", "utf8"));
|
const endemicLife = JSON.parse(await readFile("website/public/data/mhwilds/endemic_life.json", "utf8"));
|
||||||
const translations = JSON.parse(await readFile("website/public/data/mhwilds/i18n/fr.json", "utf8"));
|
const translations = JSON.parse(await readFile("website/public/data/mhwilds/i18n/fr.json", "utf8"));
|
||||||
const appSource = await readFile("website/src/app.js", "utf8");
|
const listingSource = await readFile("website/src/features/games/mhwilds/MhwildsListing.jsx", "utf8");
|
||||||
|
|
||||||
assert.equal(site.brand.name, "Sokko G");
|
assert.equal(site.brand.name, "Sokko G");
|
||||||
assert.equal(site.home.hero.title, "Sokko G");
|
assert.equal(site.home.hero.title, "Sokko G");
|
||||||
|
|
@ -74,17 +100,21 @@ test("mhwilds data and assets are available", async () => {
|
||||||
assert.ok(monsters.monsters.length > 0);
|
assert.ok(monsters.monsters.length > 0);
|
||||||
assert.ok(endemicLife.endemicLife.length > 0);
|
assert.ok(endemicLife.endemicLife.length > 0);
|
||||||
assert.equal(translations.monsters, "monstres");
|
assert.equal(translations.monsters, "monstres");
|
||||||
assert.match(appSource, /renderMhwildsListing/);
|
assert.match(listingSource, /function MhwildsListing/);
|
||||||
assert.match(appSource, /flip-monster-card/);
|
const monsterCardSource = await readFile("website/src/features/games/mhwilds/cards/MonsterCard.jsx", "utf8");
|
||||||
|
assert.match(monsterCardSource, /monster-card/);
|
||||||
await readFile("website/public/static/img/mhwilds/chatacabra.png");
|
await readFile("website/public/static/img/mhwilds/chatacabra.png");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("server supports local env configuration", async () => {
|
test("server and vite support local env configuration", async () => {
|
||||||
const server = await readFile("server.mjs", "utf8");
|
const server = await readFile("server.mjs", "utf8");
|
||||||
|
const viteConfig = await readFile("vite.config.js", "utf8");
|
||||||
const envExample = await readFile(".env.example", "utf8");
|
const envExample = await readFile(".env.example", "utf8");
|
||||||
|
|
||||||
assert.match(server, /function loadEnvFile/);
|
assert.match(server, /function loadEnvFile/);
|
||||||
assert.match(server, /process\.env\.PORT/);
|
assert.match(server, /process\.env\.PORT/);
|
||||||
|
assert.match(viteConfig, /function loadEnvFile/);
|
||||||
|
assert.match(viteConfig, /process\.env\.PORT/);
|
||||||
assert.match(envExample, /PORT=5173/);
|
assert.match(envExample, /PORT=5173/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -102,6 +132,7 @@ test("legacy svg icons are available for reuse", async () => {
|
||||||
await readFile("website/public/static/icons/rows.svg", "utf8");
|
await readFile("website/public/static/icons/rows.svg", "utf8");
|
||||||
await readFile("website/public/static/icons/rubber.svg", "utf8");
|
await readFile("website/public/static/icons/rubber.svg", "utf8");
|
||||||
await readFile("website/public/static/icons/trashcan.svg", "utf8");
|
await readFile("website/public/static/icons/trashcan.svg", "utf8");
|
||||||
|
await readFile("website/public/static/icons/zoom.svg", "utf8");
|
||||||
await readFile("website/public/favicon.ico");
|
await readFile("website/public/favicon.ico");
|
||||||
await readFile("website/public/static/img/dragon.png");
|
await readFile("website/public/static/img/dragon.png");
|
||||||
await readFile("website/public/static/img/toolbox-icons/controller.svg", "utf8");
|
await readFile("website/public/static/img/toolbox-icons/controller.svg", "utf8");
|
||||||
|
|
|
||||||
39
vite.config.js
Normal file
39
vite.config.js
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
function loadEnvFile(file = ".env") {
|
||||||
|
if (!existsSync(file)) return;
|
||||||
|
|
||||||
|
readFileSync(file, "utf8").split(/\r?\n/).forEach((line) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) return;
|
||||||
|
|
||||||
|
const separatorIndex = trimmed.indexOf("=");
|
||||||
|
if (separatorIndex < 1) return;
|
||||||
|
|
||||||
|
const key = trimmed.slice(0, separatorIndex).trim();
|
||||||
|
const value = trimmed.slice(separatorIndex + 1).trim().replace(/^["']|["']$/g, "");
|
||||||
|
if (!process.env[key]) process.env[key] = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadEnvFile();
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: "website",
|
||||||
|
publicDir: "public",
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
port: Number(process.env.PORT || 5173)
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
port: Number(process.env.PORT || 4173)
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
emptyOutDir: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -10,12 +10,9 @@
|
||||||
<meta name="theme-color" content="#070913" />
|
<meta name="theme-color" content="#070913" />
|
||||||
<title>Sokko G</title>
|
<title>Sokko G</title>
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||||
<link rel="preload" href="/src/styles.css" as="style" />
|
|
||||||
<link rel="modulepreload" href="/src/app.js" />
|
|
||||||
<link rel="stylesheet" href="/src/styles.css" />
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/app.js"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"home": "Accueil",
|
"home": "Accueil",
|
||||||
"toolboxes": "Toolboxes",
|
"toolboxes": "Toolboxes",
|
||||||
"games": "Pages jeux",
|
"games": "Jeux",
|
||||||
"mobileGames": "Infos"
|
"mobileGames": "Infos"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
|
|
|
||||||
17
website/public/static/icons/zoom.svg
Normal file
17
website/public/static/icons/zoom.svg
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
|
||||||
|
|
||||||
|
<title>zoom</title>
|
||||||
|
<desc>Created with Sketch Beta.</desc>
|
||||||
|
<defs>
|
||||||
|
|
||||||
|
</defs>
|
||||||
|
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
|
||||||
|
<g id="Icon-Set" sketch:type="MSLayerGroup" transform="translate(-152.000000, -983.000000)" fill="#000000">
|
||||||
|
<path d="M176.972,989 L172,989 C171.448,989 171,989.448 171,990 C171,990.553 171.448,991 172,991 L174.628,991 L169.83,995.799 L171.244,997.213 L176.022,992.435 L176,995 C176,995.553 176.448,996 177,996 C177.552,996 178,995.553 178,995 L178,990 C178,989.704 177.877,989.465 177.684,989.301 C177.502,989.115 177.251,989 176.972,989 L176.972,989 Z M182,1011 C182,1012.1 181.104,1013 180,1013 L156,1013 C154.896,1013 154,1012.1 154,1011 L154,987 C154,985.896 154.896,985 156,985 L180,985 C181.104,985 182,985.896 182,987 L182,1011 L182,1011 Z M180,983 L156,983 C153.791,983 152,984.791 152,987 L152,1011 C152,1013.21 153.791,1015 156,1015 L180,1015 C182.209,1015 184,1013.21 184,1011 L184,987 C184,984.791 182.209,983 180,983 L180,983 Z M164.756,1000.79 L159.978,1005.57 L160,1003 C160,1002.45 159.552,1002 159,1002 C158.448,1002 158,1002.45 158,1003 L158,1008 C158,1008.3 158.123,1008.54 158.316,1008.7 C158.497,1008.88 158.749,1009 159.028,1009 L164,1009 C164.552,1009 165,1008.55 165,1008 C165,1007.45 164.552,1007 164,1007 L161.372,1007 L166.17,1002.2 L164.756,1000.79 L164.756,1000.79 Z M177,1002 C176.448,1002 176,1002.45 176,1003 L176.022,1005.57 L171.244,1000.79 L169.83,1002.2 L174.628,1007 L172,1007 C171.448,1007 171,1007.45 171,1008 C171,1008.55 171.448,1009 172,1009 L176.972,1009 C177.251,1009 177.503,1008.88 177.684,1008.7 C177.877,1008.54 178,1008.3 178,1008 L178,1003 C178,1002.45 177.552,1002 177,1002 L177,1002 Z M164,991 C164.552,991 165,990.553 165,990 C165,989.448 164.552,989 164,989 L159.028,989 C158.749,989 158.498,989.115 158.316,989.301 C158.123,989.465 158,989.704 158,990 L158,995 C158,995.553 158.448,996 159,996 C159.552,996 160,995.553 160,995 L159.978,992.435 L164.756,997.213 L166.17,995.799 L161.372,991 L164,991 L164,991 Z" id="zoom" sketch:type="MSShapeGroup">
|
||||||
|
|
||||||
|
</path>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
2300
website/src/app.js
2300
website/src/app.js
File diff suppressed because it is too large
Load diff
4
website/src/components/Icon.jsx
Normal file
4
website/src/components/Icon.jsx
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export function Icon({ name }) {
|
||||||
|
const className = name === "trash" ? "ui-icon-trash" : `ui-icon-${name}`;
|
||||||
|
return <span className={`ui-icon ${className}`} aria-hidden="true" />;
|
||||||
|
}
|
||||||
30
website/src/features/games/GameRoute.jsx
Normal file
30
website/src/features/games/GameRoute.jsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { GamesPage } from "./GamesPage.jsx";
|
||||||
|
import { MhwildsPage } from "./mhwilds/MhwildsPage.jsx";
|
||||||
|
|
||||||
|
export function GameRoute(props) {
|
||||||
|
const { gameId, games } = props;
|
||||||
|
const game = games.find((item) => item.id === gameId);
|
||||||
|
|
||||||
|
if (!game) return <GamesPage {...props} />;
|
||||||
|
if (game.id === "mhwilds") return <MhwildsPage {...props} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="game-hero" style={{ background: game.cover }}>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{game.eyebrow || "Guide de jeu"}</p>
|
||||||
|
<h1>{game.title}</h1>
|
||||||
|
<p>{game.summary}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="info-grid">
|
||||||
|
{game.sections.map((section) => (
|
||||||
|
<article className="info-panel" key={section.title}>
|
||||||
|
<h2>{section.title}</h2>
|
||||||
|
<ul>{section.items.map((item) => <li key={item}>{item}</li>)}</ul>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
website/src/features/games/GamesPage.jsx
Normal file
35
website/src/features/games/GamesPage.jsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
export function GamesPage({ games, gamesError }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="page-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Pages informatives</p>
|
||||||
|
<h1>Jeux disponibles</h1>
|
||||||
|
<p>Choisissez un jeu pour consulter ses données maintenues et associer une toolbox locale.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="game-list">
|
||||||
|
{games.length ? games.map((game) => (
|
||||||
|
<article className="card game-card" key={game.id}>
|
||||||
|
<div className="card-cover game-card-cover" style={{ "--game-cover": game.cover || "var(--gradient-nebula)" }}>
|
||||||
|
<img src={game.image || ""} alt={game.title} loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<p className="eyebrow">{game.eyebrow || "Guide de jeu"}</p>
|
||||||
|
<h2>{game.title}</h2>
|
||||||
|
<p>{game.summary}</p>
|
||||||
|
<div className="card-actions">
|
||||||
|
<a className="button primary" href={`#/games/${game.id}`}>Ouvrir</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)) : (
|
||||||
|
<div className="empty">
|
||||||
|
<h2>Aucun jeu disponible</h2>
|
||||||
|
<p>{gamesError || "Ajoutez des entrées dans /data/games.json."}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
website/src/features/games/mhwilds/MhwildsFilters.jsx
Normal file
54
website/src/features/games/mhwilds/MhwildsFilters.jsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
|
import { assetPath } from "./utils.js";
|
||||||
|
|
||||||
|
export function MhwildsFilters({ category, filterKey, options, filters, setFilters, t }) {
|
||||||
|
const activeFilters = filters[category];
|
||||||
|
const selected = new Set(activeFilters[filterKey]);
|
||||||
|
const logicLabel = activeFilters.logic === "or" ? t("or", { capitalize: true }) : t("and", { capitalize: true });
|
||||||
|
const updateCategory = (updater) => setFilters((state) => ({ ...state, [category]: updater(state[category]) }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="filter-panel">
|
||||||
|
<div className="filter-panel-head">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Filtres</p>
|
||||||
|
<h2>{category === "monsters" ? t("weaknesses", { capitalize: true }) : t("locations", { capitalize: true })}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="filter-reset-button"
|
||||||
|
onClick={() => updateCategory(() => ({ name: "", [filterKey]: [], logic: "and" }))}
|
||||||
|
aria-label={t("reset", { capitalize: true })}
|
||||||
|
title={t("reset", { capitalize: true })}
|
||||||
|
>
|
||||||
|
<Icon name="rubber" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label className="field compact">
|
||||||
|
<span>{t("name", { capitalize: true })}</span>
|
||||||
|
<input value={activeFilters.name} placeholder="Rechercher..." onChange={(event) => updateCategory((state) => ({ ...state, name: event.target.value }))} />
|
||||||
|
</label>
|
||||||
|
<div className="filter-logic">
|
||||||
|
<span>Correspondance</span>
|
||||||
|
<button onClick={() => updateCategory((state) => ({ ...state, logic: state.logic === "and" ? "or" : "and" }))}>{logicLabel}</button>
|
||||||
|
</div>
|
||||||
|
<div className="filter-options legacy-scrollbar">
|
||||||
|
{options.map((option) => (
|
||||||
|
<label className={`filter-chip ${selected.has(option) ? "active" : ""}`} key={option}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(option)}
|
||||||
|
onChange={(event) => updateCategory((state) => {
|
||||||
|
const values = new Set(state[filterKey]);
|
||||||
|
if (event.target.checked) values.add(option);
|
||||||
|
else values.delete(option);
|
||||||
|
return { ...state, [filterKey]: [...values] };
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<img src={assetPath(option)} alt="" />
|
||||||
|
<span>{t(option, { capitalize: true })}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
website/src/features/games/mhwilds/MhwildsListing.jsx
Normal file
52
website/src/features/games/mhwilds/MhwildsListing.jsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { EndemicCard } from "./cards/EndemicCard.jsx";
|
||||||
|
import { MonsterCard } from "./cards/MonsterCard.jsx";
|
||||||
|
import { MhwildsFilters } from "./MhwildsFilters.jsx";
|
||||||
|
import { getFilteredMhwildsItems, getUniqueConditionValues } from "./utils.js";
|
||||||
|
|
||||||
|
export function MhwildsListing({ category, mhwilds, filters, setFilters, t }) {
|
||||||
|
const isMonsters = category === "monsters";
|
||||||
|
const filterKey = isMonsters ? "weaknesses" : "locations";
|
||||||
|
const items = isMonsters ? mhwilds.monsters : mhwilds.endemic;
|
||||||
|
const options = getUniqueConditionValues(items, filterKey, t);
|
||||||
|
const visible = useMemo(() => getFilteredMhwildsItems({ category, mhwilds, filters, t }), [category, mhwilds, filters, t]);
|
||||||
|
const label = isMonsters ? t("monsters", { capitalize: true }) : t("endemic life", { capitalize: true });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="page-heading mhwilds-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Monster Hunter Wilds</p>
|
||||||
|
<div className="mhwilds-title-row">
|
||||||
|
<h1>{label}</h1>
|
||||||
|
<span className="results-count">{visible.length} / {items.length}</span>
|
||||||
|
</div>
|
||||||
|
<p>{isMonsters ? "Filtrez les monstres par nom et faiblesses, puis consultez leurs dégâts détaillés." : "Filtrez la faune par nom et zones d'apparition."}</p>
|
||||||
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
<a className={`button ${isMonsters ? "primary" : ""}`} href="#/games/mhwilds/monsters">Monstres</a>
|
||||||
|
<a className={`button ${!isMonsters ? "primary" : ""}`} href="#/games/mhwilds/endemic">Faune</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="mhwilds-layout" data-mhwilds-category={category}>
|
||||||
|
<aside className="mhwilds-filters">
|
||||||
|
<MhwildsFilters category={category} filterKey={filterKey} options={options} filters={filters} setFilters={setFilters} t={t} />
|
||||||
|
</aside>
|
||||||
|
<section className="mhwilds-results" aria-live="polite">
|
||||||
|
<div className={`mhwilds-grid ${isMonsters ? "monster-grid" : "endemic-grid"}`}>
|
||||||
|
{visible.length ? visible.map((item) => (
|
||||||
|
isMonsters
|
||||||
|
? <MonsterCard key={item.name} monster={item} t={t} />
|
||||||
|
: <EndemicCard key={item.name} item={item} t={t} />
|
||||||
|
)) : (
|
||||||
|
<div className="empty">
|
||||||
|
<h2>Aucun résultat</h2>
|
||||||
|
<p>Ajustez la recherche ou réinitialisez les filtres actifs.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
website/src/features/games/mhwilds/MhwildsOverview.jsx
Normal file
27
website/src/features/games/mhwilds/MhwildsOverview.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { assetPath } from "./utils.js";
|
||||||
|
|
||||||
|
export function MhwildsOverview() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="game-hero mhwilds-hero">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Guide de jeu</p>
|
||||||
|
<h1>Monster Hunter: Wilds</h1>
|
||||||
|
<p>Consultez rapidement les monstres, faiblesses, afflictions, hitzones et emplacements de faune endémique.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="mhwilds-home-grid">
|
||||||
|
<a className="feature mhwilds-home-card" href="#/games/mhwilds/monsters">
|
||||||
|
<img src={assetPath("arkveld")} alt="" />
|
||||||
|
<strong>Monstres</strong>
|
||||||
|
<span>Recherche, filtres par faiblesse et tableau de dégâts par partie.</span>
|
||||||
|
</a>
|
||||||
|
<a className="feature mhwilds-home-card" href="#/games/mhwilds/endemic">
|
||||||
|
<img src={assetPath("vigorwasp")} alt="" />
|
||||||
|
<strong>Faune endémique</strong>
|
||||||
|
<span>Faune endémique et aquatique filtrable par localisation.</span>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
website/src/features/games/mhwilds/MhwildsPage.jsx
Normal file
45
website/src/features/games/mhwilds/MhwildsPage.jsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { MhwildsListing } from "./MhwildsListing.jsx";
|
||||||
|
import { MhwildsOverview } from "./MhwildsOverview.jsx";
|
||||||
|
|
||||||
|
export function MhwildsPage({ category, mhwilds, filters, setFilters, t }) {
|
||||||
|
const activeCategory = category === "monsters" || category === "endemic" ? category : "";
|
||||||
|
|
||||||
|
if (!mhwilds.loaded) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="game-hero mhwilds-hero">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Monster Hunter Wilds</p>
|
||||||
|
<h1>Monster Hunter: Wilds</h1>
|
||||||
|
<p>Chargement des données de chasse, faune endémique et filtres associés.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="empty">
|
||||||
|
<h2>Chargement</h2>
|
||||||
|
<p>Préparation des données locales...</p>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mhwilds.error) {
|
||||||
|
return (
|
||||||
|
<section className="empty">
|
||||||
|
<h1>Impossible de charger MH Wilds</h1>
|
||||||
|
<p>{mhwilds.error}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeCategory) return <MhwildsOverview />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MhwildsListing
|
||||||
|
category={activeCategory}
|
||||||
|
mhwilds={mhwilds}
|
||||||
|
filters={filters}
|
||||||
|
setFilters={setFilters}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
website/src/features/games/mhwilds/cards/DamageTable.jsx
Normal file
34
website/src/features/games/mhwilds/cards/DamageTable.jsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { assetPath } from "../utils.js";
|
||||||
|
|
||||||
|
export function DamageTable({ rows, t }) {
|
||||||
|
if (!rows.length) return null;
|
||||||
|
|
||||||
|
const columns = Object.keys(rows[0]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="damage-table-wrap legacy-scrollbar">
|
||||||
|
<table className="damage-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<th key={column}>
|
||||||
|
{column === "name" ? "" : <img src={assetPath(column)} alt={t(column, { capitalize: true })} title={t(column, { capitalize: true })} />}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, rowIndex) => (
|
||||||
|
<tr key={`${row.name}-${rowIndex}`}>
|
||||||
|
{columns.map((column) => column === "name" ? (
|
||||||
|
<td key={column} title={t(row[column], { capitalize: true })}>{t(row[column], { capitalize: true })}</td>
|
||||||
|
) : (
|
||||||
|
<td key={column}><img src={assetPath(`${row[column]}-stars`)} alt={`${row[column]} étoiles`} /></td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
website/src/features/games/mhwilds/cards/EndemicCard.jsx
Normal file
21
website/src/features/games/mhwilds/cards/EndemicCard.jsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { IconRow } from "./IconRow.jsx";
|
||||||
|
import { assetPath, getConditionValues, normalizeText } from "../utils.js";
|
||||||
|
|
||||||
|
export function EndemicCard({ item, t }) {
|
||||||
|
const locations = getConditionValues(item.locations);
|
||||||
|
const description = t(item.description);
|
||||||
|
const hasDescription = normalizeText(description) && normalizeText(description) !== normalizeText("à compléter");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="mhwilds-card endemic-card">
|
||||||
|
<div className="mhwilds-card-art">
|
||||||
|
<img src={assetPath(item.name)} alt={t(item.name, { capitalize: true })} loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div className="mhwilds-card-body">
|
||||||
|
<h2>{t(item.name, { capitalize: true })}</h2>
|
||||||
|
<IconRow label={t("locations", { capitalize: true })} values={locations} t={t} />
|
||||||
|
{hasDescription && <p>{description}</p>}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
website/src/features/games/mhwilds/cards/IconRow.jsx
Normal file
14
website/src/features/games/mhwilds/cards/IconRow.jsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { assetPath } from "../utils.js";
|
||||||
|
|
||||||
|
export function IconRow({ label, values, t }) {
|
||||||
|
return (
|
||||||
|
<div className="mhwilds-icon-row">
|
||||||
|
<span>{label}</span>
|
||||||
|
<div>
|
||||||
|
{values.map((value) => value === "none"
|
||||||
|
? <em key={value}>-</em>
|
||||||
|
: <img key={value} src={assetPath(value)} alt={t(value, { capitalize: true })} title={t(value, { capitalize: true })} loading="lazy" />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
website/src/features/games/mhwilds/cards/MonsterCard.jsx
Normal file
47
website/src/features/games/mhwilds/cards/MonsterCard.jsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { DamageTable } from "./DamageTable.jsx";
|
||||||
|
import { IconRow } from "./IconRow.jsx";
|
||||||
|
import { assetPath, getConditionValues } from "../utils.js";
|
||||||
|
|
||||||
|
export function MonsterCard({ monster, t }) {
|
||||||
|
const [flipped, setFlipped] = useState(false);
|
||||||
|
const weaknesses = getConditionValues(monster.weaknesses);
|
||||||
|
const ailments = getConditionValues(monster.ailments).filter((value) => value !== "none");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`mhwilds-card monster-card ${flipped ? "is-flipped" : ""}`}
|
||||||
|
onClick={() => setFlipped(!flipped)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
event.preventDefault();
|
||||||
|
setFlipped((value) => !value);
|
||||||
|
}}
|
||||||
|
tabIndex="0"
|
||||||
|
role="button"
|
||||||
|
aria-pressed={flipped}
|
||||||
|
aria-label={`Afficher les dégâts de ${t(monster.name, { capitalize: true })}`}
|
||||||
|
>
|
||||||
|
<div className="mhwilds-card-inner">
|
||||||
|
<div className="mhwilds-card-face mhwilds-card-front">
|
||||||
|
<div className="mhwilds-card-art">
|
||||||
|
<img src={assetPath(monster.name)} alt={t(monster.name, { capitalize: true })} loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div className="mhwilds-card-body">
|
||||||
|
<p className="eyebrow">{t(monster.type, { capitalize: true })}</p>
|
||||||
|
<h2>{t(monster.name, { capitalize: true })}</h2>
|
||||||
|
<IconRow label={t("weaknesses", { capitalize: true })} values={weaknesses} t={t} />
|
||||||
|
<IconRow label={t("ailments", { capitalize: true })} values={ailments.length ? ailments : ["none"]} t={t} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mhwilds-card-face mhwilds-card-back">
|
||||||
|
<div className="mhwilds-card-body">
|
||||||
|
<p className="eyebrow">Détails</p>
|
||||||
|
<h2>{t(monster.name, { capitalize: true })}</h2>
|
||||||
|
</div>
|
||||||
|
<DamageTable rows={monster.damage || []} t={t} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
website/src/features/games/mhwilds/utils.js
Normal file
38
website/src/features/games/mhwilds/utils.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
const MHWILDS_IMG_PATH = "/static/img/mhwilds";
|
||||||
|
|
||||||
|
export function assetPath(name) {
|
||||||
|
return encodeURI(`${MHWILDS_IMG_PATH}/${name}.png`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeText(value) {
|
||||||
|
return String(value || "").trim().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/\s+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConditionValues(conditions = []) {
|
||||||
|
return [...new Set(conditions.flatMap((condition) => condition.values || []))];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUniqueConditionValues(items, property, translate) {
|
||||||
|
return [...new Set(items.flatMap((item) => getConditionValues(item[property])))]
|
||||||
|
.filter((value) => value !== "none")
|
||||||
|
.sort((a, b) => translate(a).localeCompare(translate(b), "fr"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFilteredMhwildsItems({ category, mhwilds, filters, t }) {
|
||||||
|
const items = category === "monsters" ? mhwilds.monsters : mhwilds.endemic;
|
||||||
|
const activeFilters = filters[category];
|
||||||
|
const filterKey = category === "monsters" ? "weaknesses" : "locations";
|
||||||
|
const selected = activeFilters[filterKey];
|
||||||
|
const search = normalizeText(activeFilters.name);
|
||||||
|
|
||||||
|
return items.filter((item) => {
|
||||||
|
const nameMatches = !search || normalizeText(t(item.name)).includes(search) || normalizeText(item.name).includes(search);
|
||||||
|
if (!nameMatches) return false;
|
||||||
|
if (!selected.length) return true;
|
||||||
|
|
||||||
|
const values = getConditionValues(item[filterKey]);
|
||||||
|
return activeFilters.logic === "or" && selected.length >= 2
|
||||||
|
? selected.some((value) => values.includes(value))
|
||||||
|
: selected.every((value) => values.includes(value));
|
||||||
|
});
|
||||||
|
}
|
||||||
66
website/src/features/toolboxes/modules/ChecklistModule.jsx
Normal file
66
website/src/features/toolboxes/modules/ChecklistModule.jsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function ChecklistModule({ toolboxId, moduleId, context }) {
|
||||||
|
const data = context.normalizeChecklistData(context.getModuleData(toolboxId, moduleId, { items: [] }));
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const [qty, setQty] = useState(1);
|
||||||
|
|
||||||
|
function save(items) {
|
||||||
|
context.setModuleData(toolboxId, moduleId, { items });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addItem(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const cleanLabel = label.trim();
|
||||||
|
if (!cleanLabel) return;
|
||||||
|
save([...data.items, { id: context.uid("item"), label: cleanLabel, qtyTarget: Math.max(1, Number(qty) || 1), qtyCurrent: 0 }]);
|
||||||
|
setLabel("");
|
||||||
|
setQty(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<form className="inline-form checklist-add-form" onSubmit={addItem}>
|
||||||
|
<input name="label" placeholder="Nouvel item" value={label} onChange={(event) => setLabel(event.target.value)} />
|
||||||
|
<input className="checklist-qty-input" name="qty" type="number" min="1" value={qty} onChange={(event) => setQty(event.target.value)} aria-label="Quantité cible" />
|
||||||
|
<button className="primary">Ajouter</button>
|
||||||
|
</form>
|
||||||
|
<ul className="checklist">
|
||||||
|
{data.items.map((item) => (
|
||||||
|
<ChecklistItem key={item.id} item={item} toolboxId={toolboxId} moduleId={moduleId} context={context} items={data.items} save={save} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChecklistItem({ item, context, items, save }) {
|
||||||
|
const done = context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget;
|
||||||
|
|
||||||
|
function updateItem(updater) {
|
||||||
|
save(items.map((entry) => entry.id === item.id ? updater(entry) : entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className={`checklist-item ${done ? "is-complete" : ""}`}>
|
||||||
|
<div className="checklist-item-main">
|
||||||
|
{item.qtyTarget === 1 ? (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={done}
|
||||||
|
onChange={(event) => updateItem((entry) => ({ ...entry, qtyTarget: 1, qtyCurrent: event.target.checked ? 1 : 0 }))}
|
||||||
|
aria-label={`Terminer ${item.label}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="checklist-qty-controls" aria-label={`Quantité ${item.label}`}>
|
||||||
|
<button type="button" onClick={() => updateItem((entry) => ({ ...entry, qtyCurrent: context.clampQty(entry.qtyCurrent - 1, entry.qtyTarget) }))} aria-label="Retirer une quantité">-</button>
|
||||||
|
<small>{context.clampQty(item.qtyCurrent, item.qtyTarget)}/{item.qtyTarget}</small>
|
||||||
|
<button type="button" onClick={() => updateItem((entry) => ({ ...entry, qtyCurrent: context.clampQty(entry.qtyCurrent + 1, entry.qtyTarget) }))} aria-label="Ajouter une quantité">+</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => save(items.filter((entry) => entry.id !== item.id))} aria-label={`Supprimer ${item.label}`}>×</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
website/src/features/toolboxes/modules/NotepadModule.jsx
Normal file
18
website/src/features/toolboxes/modules/NotepadModule.jsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function NotepadModule({ toolboxId, moduleId, context }) {
|
||||||
|
const data = context.getModuleData(toolboxId, moduleId, { text: "" });
|
||||||
|
const [text, setText] = useState(data.text || "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className="notepad"
|
||||||
|
value={text}
|
||||||
|
onChange={(event) => {
|
||||||
|
setText(event.target.value);
|
||||||
|
context.setModuleData(toolboxId, moduleId, { text: event.target.value });
|
||||||
|
}}
|
||||||
|
placeholder="Notes rapides..."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
website/src/features/toolboxes/modules/ScreenshotsModule.jsx
Normal file
72
website/src/features/toolboxes/modules/ScreenshotsModule.jsx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function ScreenshotsModule({ toolboxId, moduleId, context }) {
|
||||||
|
const data = context.getModuleData(toolboxId, moduleId, { shots: [] });
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
|
||||||
|
async function addFiles(files) {
|
||||||
|
if (await context.addScreenshotFiles(toolboxId, moduleId, files)) {
|
||||||
|
setDragOver(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label
|
||||||
|
className={`dropzone ${dragOver ? "is-drag-over" : ""}`}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "copy";
|
||||||
|
setDragOver(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
addFiles(event.dataTransfer.files);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="file" accept="image/*" multiple hidden onChange={(event) => addFiles(event.target.files)} />
|
||||||
|
Ajouter des screenshots
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="paste-target"
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
role="textbox"
|
||||||
|
aria-label="Coller une image depuis le presse-papiers"
|
||||||
|
onFocus={(event) => {
|
||||||
|
if (event.currentTarget.textContent.trim() === "Coller une image ici") event.currentTarget.textContent = "";
|
||||||
|
}}
|
||||||
|
onBlur={(event) => {
|
||||||
|
if (!event.currentTarget.textContent.trim()) event.currentTarget.textContent = "Coller une image ici";
|
||||||
|
}}
|
||||||
|
onPaste={(event) => {
|
||||||
|
const files = [...(event.clipboardData?.items || [])]
|
||||||
|
.filter((item) => item.type.startsWith("image/"))
|
||||||
|
.map((item) => item.getAsFile())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!files.length) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.currentTarget.textContent = "Coller une image ici";
|
||||||
|
addFiles(files);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Coller une image ici
|
||||||
|
</div>
|
||||||
|
<div className="shots">
|
||||||
|
{data.shots.map((shot) => (
|
||||||
|
<figure key={shot.id}>
|
||||||
|
<button className="shot-preview" onClick={() => context.setScreenshot(shot)} aria-label="Agrandir le screenshot">
|
||||||
|
<img src={shot.dataUrl} alt="Screenshot" />
|
||||||
|
</button>
|
||||||
|
<button className="shot-delete-button danger" onClick={() => {
|
||||||
|
context.setModuleData(toolboxId, moduleId, { shots: data.shots.filter((item) => item.id !== shot.id) });
|
||||||
|
}} aria-label="Supprimer le screenshot" title="Supprimer">
|
||||||
|
<span className="ui-icon ui-icon-trash" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
website/src/features/toolboxes/modules/index.jsx
Normal file
132
website/src/features/toolboxes/modules/index.jsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { ChecklistModule } from "./ChecklistModule.jsx";
|
||||||
|
import { NotepadModule } from "./NotepadModule.jsx";
|
||||||
|
import { ScreenshotsModule } from "./ScreenshotsModule.jsx";
|
||||||
|
|
||||||
|
const MODULE_COMPONENTS = {
|
||||||
|
notepad: { label: "Bloc notes", icon: "notepad", Component: NotepadModule },
|
||||||
|
checklist: { label: "Checklist", icon: "checklist", Component: ChecklistModule },
|
||||||
|
screenshots: { label: "Screenshots", icon: "picture", Component: ScreenshotsModule }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TOOLBOX_MODULES = Object.fromEntries(Object.entries(MODULE_COMPONENTS).map(([type, module]) => [
|
||||||
|
type,
|
||||||
|
{ label: module.label, icon: module.icon }
|
||||||
|
]));
|
||||||
|
|
||||||
|
export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onDelete, onMove }) {
|
||||||
|
if (moduleColumns === 1) {
|
||||||
|
return (
|
||||||
|
<section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}>
|
||||||
|
{toolbox.modules.map((module) => <ModuleShell key={module.id} toolbox={toolbox} module={module} context={context} onRename={onRename} onDelete={onDelete} onMove={onMove} />)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [[], []];
|
||||||
|
toolbox.modules.forEach((module, index) => columns[index % 2].push(module));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}>
|
||||||
|
{columns.map((modules, index) => (
|
||||||
|
<div className="module-column" key={index}>
|
||||||
|
{modules.map((module) => <ModuleShell key={module.id} toolbox={toolbox} module={module} context={context} onRename={onRename} onDelete={onDelete} onMove={onMove} />)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModuleShell({ toolbox, module, context, onRename, onDelete, onMove }) {
|
||||||
|
const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
|
||||||
|
const Component = definition.Component;
|
||||||
|
const label = definition.label || module.type;
|
||||||
|
|
||||||
|
function handleDragStart(event) {
|
||||||
|
if (event.target.closest("button, input, select, textarea, [contenteditable='true']")) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.currentTarget.classList.add("is-dragging");
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData("text/plain", module.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(event) {
|
||||||
|
const fromModuleId = event.dataTransfer.getData("text/plain");
|
||||||
|
if (!fromModuleId || fromModuleId === module.id) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "move";
|
||||||
|
event.currentTarget.classList.add("is-drop-target");
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
event.currentTarget.classList.toggle("drop-after", event.clientY > rect.top + rect.height / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(event) {
|
||||||
|
const fromModuleId = event.dataTransfer.getData("text/plain");
|
||||||
|
if (!fromModuleId || fromModuleId === module.id) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
onMove(fromModuleId, module.id, event.clientY > rect.top + rect.height / 2 ? "after" : "before");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDragClasses(event) {
|
||||||
|
event.currentTarget.classList.remove("is-dragging", "is-drop-target", "drop-after");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="module"
|
||||||
|
data-toolbox-id={toolbox.id}
|
||||||
|
data-module-id={module.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={clearDragClasses}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragEnd={clearDragClasses}
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<span className="module-drag-handle" aria-hidden="true" title="Déplacer l'outil" />
|
||||||
|
<span className="module-icon" aria-hidden="true">
|
||||||
|
<span className={`module-icon-svg module-icon-${definition.icon || "notepad"}`} />
|
||||||
|
</span>
|
||||||
|
<EditableModuleTitle value={module.title || label} fallback={label} onSave={(title) => onRename(module.id, title)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button className="module-delete-button danger" onClick={() => onDelete(module.id)} aria-label={`Retirer ${module.title || label}`} title="Retirer">
|
||||||
|
<span className="ui-icon ui-icon-trash" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<Component toolboxId={toolbox.id} moduleId={module.id} context={context} />
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditableModuleTitle({ value, fallback, onSave }) {
|
||||||
|
return (
|
||||||
|
<h2
|
||||||
|
className="module-title"
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
spellCheck="false"
|
||||||
|
title="Cliquer pour renommer"
|
||||||
|
onFocus={(event) => { event.currentTarget.dataset.previousTitle = event.currentTarget.textContent.trim(); }}
|
||||||
|
onBlur={(event) => onSave(event.currentTarget.textContent.trim() || fallback)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.currentTarget.blur();
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.currentTarget.textContent = event.currentTarget.dataset.previousTitle || value;
|
||||||
|
event.currentTarget.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
}
|
||||||
1205
website/src/main.jsx
Normal file
1205
website/src/main.jsx
Normal file
File diff suppressed because it is too large
Load diff
89
website/src/styles/_tokens.scss
Normal file
89
website/src/styles/_tokens.scss
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--color-bg-page: #070913;
|
||||||
|
--color-bg-deep: #050711;
|
||||||
|
--color-bg-sidebar: #0b0e1b;
|
||||||
|
--color-bg-surface: #101426;
|
||||||
|
--color-bg-surface-alt: #151a30;
|
||||||
|
--color-bg-elevated: #1a2038;
|
||||||
|
--color-bg-hover: #202745;
|
||||||
|
|
||||||
|
--color-primary: #8b5cf6;
|
||||||
|
--color-primary-hover: #9f7aea;
|
||||||
|
--color-primary-active: #7347dc;
|
||||||
|
--color-primary-soft: rgba(139, 92, 246, 0.14);
|
||||||
|
--color-primary-border: rgba(139, 92, 246, 0.45);
|
||||||
|
|
||||||
|
--color-secondary: #3b82f6;
|
||||||
|
--color-secondary-soft: rgba(59, 130, 246, 0.14);
|
||||||
|
--color-accent-cyan: #22d3ee;
|
||||||
|
--color-accent-pink: #d946ef;
|
||||||
|
--color-accent-indigo: #6366f1;
|
||||||
|
|
||||||
|
--color-text-primary: #f5f7ff;
|
||||||
|
--color-text-secondary: #b4bdd3;
|
||||||
|
--color-text-muted: #7d879f;
|
||||||
|
--color-text-disabled: #555e73;
|
||||||
|
--color-text-inverse: #080a13;
|
||||||
|
|
||||||
|
--color-border: rgba(150, 165, 205, 0.14);
|
||||||
|
--color-border-hover: rgba(150, 165, 205, 0.28);
|
||||||
|
--color-border-strong: rgba(167, 139, 250, 0.42);
|
||||||
|
|
||||||
|
--color-success: #34d399;
|
||||||
|
--color-success-soft: rgba(52, 211, 153, 0.13);
|
||||||
|
--color-warning: #fbbf24;
|
||||||
|
--color-warning-soft: rgba(251, 191, 36, 0.13);
|
||||||
|
--color-danger: #fb7185;
|
||||||
|
--color-danger-soft: rgba(251, 113, 133, 0.13);
|
||||||
|
--color-info: #38bdf8;
|
||||||
|
--color-info-soft: rgba(56, 189, 248, 0.13);
|
||||||
|
|
||||||
|
--gradient-brand: linear-gradient(135deg, #8b5cf6 0%, #6366f1 45%, #3b82f6 100%);
|
||||||
|
--gradient-nebula: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(139, 92, 246, 0.28),
|
||||||
|
rgba(59, 130, 246, 0.18),
|
||||||
|
rgba(217, 70, 239, 0.12)
|
||||||
|
);
|
||||||
|
--gradient-page:
|
||||||
|
radial-gradient(circle at 20% 10%, rgba(124, 58, 237, 0.16), transparent 34%),
|
||||||
|
radial-gradient(circle at 85% 20%, rgba(37, 99, 235, 0.12), transparent 30%),
|
||||||
|
radial-gradient(circle at 55% 90%, rgba(217, 70, 239, 0.08), transparent 35%),
|
||||||
|
#070913;
|
||||||
|
|
||||||
|
--font-size-xs: 0.75rem;
|
||||||
|
--font-size-sm: 0.875rem;
|
||||||
|
--font-size-base: 1rem;
|
||||||
|
--font-size-lg: 1.125rem;
|
||||||
|
--font-size-xl: 1.25rem;
|
||||||
|
--font-size-2xl: 1.5rem;
|
||||||
|
--font-size-3xl: 2rem;
|
||||||
|
--font-size-4xl: 2.5rem;
|
||||||
|
|
||||||
|
--space-2: 0.5rem;
|
||||||
|
--space-3: 0.75rem;
|
||||||
|
--space-4: 1rem;
|
||||||
|
--space-5: 1.25rem;
|
||||||
|
--space-6: 1.5rem;
|
||||||
|
--space-8: 2rem;
|
||||||
|
--space-10: 2.5rem;
|
||||||
|
--space-12: 3rem;
|
||||||
|
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 10px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
--radius-xl: 18px;
|
||||||
|
--radius-pill: 999px;
|
||||||
|
|
||||||
|
--shadow-sm: 0 4px 12px rgba(0, 0, 0, 0.22);
|
||||||
|
--shadow-md: 0 12px 30px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-lg: 0 24px 60px rgba(0, 0, 0, 0.42);
|
||||||
|
--shadow-primary: 0 0 28px rgba(139, 92, 246, 0.22);
|
||||||
|
--shadow-secondary: 0 0 28px rgba(59, 130, 246, 0.18);
|
||||||
|
|
||||||
|
--duration-fast: 120ms;
|
||||||
|
--duration-normal: 180ms;
|
||||||
|
--ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
@ -1,92 +1,4 @@
|
||||||
:root {
|
@use "tokens";
|
||||||
color-scheme: dark;
|
|
||||||
|
|
||||||
--color-bg-page: #070913;
|
|
||||||
--color-bg-deep: #050711;
|
|
||||||
--color-bg-sidebar: #0b0e1b;
|
|
||||||
--color-bg-surface: #101426;
|
|
||||||
--color-bg-surface-alt: #151a30;
|
|
||||||
--color-bg-elevated: #1a2038;
|
|
||||||
--color-bg-hover: #202745;
|
|
||||||
|
|
||||||
--color-primary: #8b5cf6;
|
|
||||||
--color-primary-hover: #9f7aea;
|
|
||||||
--color-primary-active: #7347dc;
|
|
||||||
--color-primary-soft: rgba(139, 92, 246, 0.14);
|
|
||||||
--color-primary-border: rgba(139, 92, 246, 0.45);
|
|
||||||
|
|
||||||
--color-secondary: #3b82f6;
|
|
||||||
--color-secondary-soft: rgba(59, 130, 246, 0.14);
|
|
||||||
--color-accent-cyan: #22d3ee;
|
|
||||||
--color-accent-pink: #d946ef;
|
|
||||||
--color-accent-indigo: #6366f1;
|
|
||||||
|
|
||||||
--color-text-primary: #f5f7ff;
|
|
||||||
--color-text-secondary: #b4bdd3;
|
|
||||||
--color-text-muted: #7d879f;
|
|
||||||
--color-text-disabled: #555e73;
|
|
||||||
--color-text-inverse: #080a13;
|
|
||||||
|
|
||||||
--color-border: rgba(150, 165, 205, 0.14);
|
|
||||||
--color-border-hover: rgba(150, 165, 205, 0.28);
|
|
||||||
--color-border-strong: rgba(167, 139, 250, 0.42);
|
|
||||||
|
|
||||||
--color-success: #34d399;
|
|
||||||
--color-success-soft: rgba(52, 211, 153, 0.13);
|
|
||||||
--color-warning: #fbbf24;
|
|
||||||
--color-warning-soft: rgba(251, 191, 36, 0.13);
|
|
||||||
--color-danger: #fb7185;
|
|
||||||
--color-danger-soft: rgba(251, 113, 133, 0.13);
|
|
||||||
--color-info: #38bdf8;
|
|
||||||
--color-info-soft: rgba(56, 189, 248, 0.13);
|
|
||||||
|
|
||||||
--gradient-brand: linear-gradient(135deg, #8b5cf6 0%, #6366f1 45%, #3b82f6 100%);
|
|
||||||
--gradient-nebula: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
rgba(139, 92, 246, 0.28),
|
|
||||||
rgba(59, 130, 246, 0.18),
|
|
||||||
rgba(217, 70, 239, 0.12)
|
|
||||||
);
|
|
||||||
--gradient-page:
|
|
||||||
radial-gradient(circle at 20% 10%, rgba(124, 58, 237, 0.16), transparent 34%),
|
|
||||||
radial-gradient(circle at 85% 20%, rgba(37, 99, 235, 0.12), transparent 30%),
|
|
||||||
radial-gradient(circle at 55% 90%, rgba(217, 70, 239, 0.08), transparent 35%),
|
|
||||||
#070913;
|
|
||||||
|
|
||||||
--font-size-xs: 0.75rem;
|
|
||||||
--font-size-sm: 0.875rem;
|
|
||||||
--font-size-base: 1rem;
|
|
||||||
--font-size-lg: 1.125rem;
|
|
||||||
--font-size-xl: 1.25rem;
|
|
||||||
--font-size-2xl: 1.5rem;
|
|
||||||
--font-size-3xl: 2rem;
|
|
||||||
--font-size-4xl: 2.5rem;
|
|
||||||
|
|
||||||
--space-2: 0.5rem;
|
|
||||||
--space-3: 0.75rem;
|
|
||||||
--space-4: 1rem;
|
|
||||||
--space-5: 1.25rem;
|
|
||||||
--space-6: 1.5rem;
|
|
||||||
--space-8: 2rem;
|
|
||||||
--space-10: 2.5rem;
|
|
||||||
--space-12: 3rem;
|
|
||||||
|
|
||||||
--radius-sm: 6px;
|
|
||||||
--radius-md: 10px;
|
|
||||||
--radius-lg: 14px;
|
|
||||||
--radius-xl: 18px;
|
|
||||||
--radius-pill: 999px;
|
|
||||||
|
|
||||||
--shadow-sm: 0 4px 12px rgba(0, 0, 0, 0.22);
|
|
||||||
--shadow-md: 0 12px 30px rgba(0, 0, 0, 0.3);
|
|
||||||
--shadow-lg: 0 24px 60px rgba(0, 0, 0, 0.42);
|
|
||||||
--shadow-primary: 0 0 28px rgba(139, 92, 246, 0.22);
|
|
||||||
--shadow-secondary: 0 0 28px rgba(59, 130, 246, 0.18);
|
|
||||||
|
|
||||||
--duration-fast: 120ms;
|
|
||||||
--duration-normal: 180ms;
|
|
||||||
--ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
@ -998,6 +910,11 @@ span {
|
||||||
-webkit-mask-image: url("/static/icons/open.svg");
|
-webkit-mask-image: url("/static/icons/open.svg");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-icon-zoom {
|
||||||
|
mask-image: url("/static/icons/zoom.svg");
|
||||||
|
-webkit-mask-image: url("/static/icons/zoom.svg");
|
||||||
|
}
|
||||||
|
|
||||||
.ui-icon-save {
|
.ui-icon-save {
|
||||||
mask-image: url("/static/icons/save.svg");
|
mask-image: url("/static/icons/save.svg");
|
||||||
-webkit-mask-image: url("/static/icons/save.svg");
|
-webkit-mask-image: url("/static/icons/save.svg");
|
||||||
|
|
@ -1531,7 +1448,13 @@ textarea:focus {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: var(--space-4);
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-viewer-button {
|
||||||
|
width: 40px;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-viewer img {
|
.screenshot-viewer img {
|
||||||
Loading…
Add table
Add a link
Reference in a new issue