This commit is contained in:
parent
db4b99aee3
commit
1dee8b528f
30 changed files with 3491 additions and 2444 deletions
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue