Files
TougliGui/src/components/GuideView.tsx

475 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from "react";
import { useStore } from "../store";
import { SectionItem, QuestItem, CombatType } from "../types";
import QuestDetailPanel from "./QuestDetailPanel";
import { TextWithCoords } from "./TextWithCoords";
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []);
return width;
}
export function combatIcon(name: string): string {
const l = name.toLowerCase();
if (l === "solo") return "🗡️";
if (l === "groupe") return "⚔️";
if (l === "donjon") return "💀";
if (
l === "combat_vagues" ||
l === "combat_tactique" ||
l === "combat_aleatoire" ||
l === "combat_zone" ||
l === "combat"
) return "🗡️";
if (l === "deplacement") return "🗺️";
if (l === "item") return "📦";
// Fallback — conserve l'ancienne logique pour les données CSV existantes
if (l.includes("solo") || l.includes("seul")) return "🗡️";
if (l.includes("group") || l.includes("groupe")) return "⚔️";
if (l.includes("donjon") || l.includes("boss")) return "💀";
return "🗡️";
}
export default function GuideView() {
const { activeGuideData, completedQuests, toggleQuest, activeProfileId, resourcesPanelCollapsed, setResourcesPanelCollapsed, resourceInventory, setResourceQuantity } = useStore();
const resourcesCollapsed = resourcesPanelCollapsed;
const setResourcesCollapsed = setResourcesPanelCollapsed;
const [selectedQuest, setSelectedQuest] = useState<{ name: string; url: string | null } | null>(null);
const windowWidth = useWindowWidth();
const resourcesIsOverlay = resourcesCollapsed || windowWidth < 500;
if (!activeGuideData) return null;
if (selectedQuest && activeProfileId) {
return (
<div style={{ flex: 1, display: "flex", overflow: "hidden", minHeight: 0 }}>
<QuestDetailPanel
questName={selectedQuest.name}
questUrl={selectedQuest.url}
profileId={activeProfileId}
onClose={() => setSelectedQuest(null)}
/>
</div>
);
}
const { name, effect, recommended_level, resources, sections, combat_legend } = activeGuideData;
const allQuests = collectAllQuests(sections);
const completedCount = allQuests.filter(q => completedQuests.has(q)).length;
const pct = allQuests.length > 0 ? Math.round((completedCount / allQuests.length) * 100) : 0;
const isDone = pct === 100;
return (
<div style={{ flex: 1, display: "flex", overflow: "hidden", minHeight: 0, position: "relative" }}>
<div className="with-scrollbar" style={{ flex: 1, overflowY: "auto", padding: "20px 24px" }}>
{/* Header */}
<div style={{ marginBottom: "20px" }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: "8px", flexWrap: "wrap" }}>
<div style={{ minWidth: 0 }}>
<h1 style={{ fontSize: "18px", fontWeight: 700, color: "#f0c040", marginBottom: "2px", wordBreak: "break-word" }}>{name}</h1>
{recommended_level && (
<div style={{ fontSize: "12px", color: "#94a3b8" }}>
Niv. recommandé : <span style={{ color: "#e2e8f0", fontWeight: 600 }}>{recommended_level}</span>
</div>
)}
</div>
<div style={{ textAlign: "right", flexShrink: 0 }}>
<div style={{ fontSize: "13px", fontWeight: 700, color: isDone ? "#4ade80" : "#f0c040" }}>
{completedCount} / {allQuests.length}
</div>
<div style={{ fontSize: "11px", color: "#94a3b8" }}>{pct}%</div>
</div>
</div>
<div style={{ height: "4px", background: "#2d3748", borderRadius: "2px", overflow: "hidden", marginTop: "10px" }}>
<div style={{
height: "100%", width: `${pct}%`,
background: isDone ? "#4ade80" : "linear-gradient(90deg, #4a9eff, #f0c040)",
borderRadius: "2px", transition: "width 0.3s ease",
}} />
</div>
{effect && (
<div style={{
marginTop: "12px", padding: "10px 14px",
background: "rgba(240,192,64,0.04)", borderRadius: "6px",
borderLeft: "3px solid rgba(240,192,64,0.4)",
fontSize: "12px", color: "#94a3b8", lineHeight: 1.6,
}}>
<span style={{ color: "#f0c040", fontWeight: 600, fontSize: "11px", display: "block", marginBottom: "3px", textTransform: "uppercase", letterSpacing: "0.05em" }}>
Effet
</span>
{effect}
</div>
)}
</div>
{/* Légende */}
{combat_legend.length > 0 && (
<Legend legend={combat_legend} />
)}
{/* Sections */}
{sections.map((section, si) => (
<div key={si} style={{ marginBottom: "24px" }}>
<h2 style={{
fontSize: "11px", fontWeight: 600, color: "#4a5568",
textTransform: "uppercase", letterSpacing: "0.1em",
marginBottom: "8px", borderBottom: "1px solid #2d3748", paddingBottom: "4px",
}}>
{section.name}
</h2>
{section.items.map((item, ii) => (
<SectionItemView key={ii} item={item} completedQuests={completedQuests} onToggle={toggleQuest} onSelect={setSelectedQuest} />
))}
</div>
))}
</div>
{/* Resources panel */}
{resources.length > 0 && (
<div style={{
position: resourcesIsOverlay ? "absolute" : "relative",
right: 0,
top: 0,
bottom: 0,
zIndex: 10,
width: resourcesCollapsed ? "36px" : "190px",
flexShrink: 0,
background: resourcesCollapsed ? "transparent" : "#161b22",
borderLeft: resourcesCollapsed ? "none" : "1px solid #2d3748",
display: "flex",
flexDirection: "column",
overflow: "hidden",
transition: "width 0.2s ease, background 0.2s ease",
}}>
{/* Toggle */}
<button
onClick={() => setResourcesCollapsed(!resourcesCollapsed)}
title={resourcesCollapsed ? "Afficher les ressources" : "Masquer les ressources"}
style={{
width: "100%",
height: "36px",
flexShrink: 0,
background: resourcesCollapsed ? "rgba(22,27,34,0.9)" : "transparent",
border: resourcesCollapsed ? "1px solid #2d3748" : "none",
borderRight: "none",
borderRadius: resourcesCollapsed ? "6px 0 0 6px" : "0",
borderBottom: "1px solid #2d3748",
color: "#4a5568",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: resourcesCollapsed ? "center" : "flex-start",
padding: "0 10px",
gap: "6px",
marginTop: resourcesCollapsed ? "8px" : "0",
transition: "all 0.15s",
}}
onMouseEnter={e => (e.currentTarget.style.color = "#f0c040")}
onMouseLeave={e => (e.currentTarget.style.color = "#4a5568")}
>
<span style={{
fontSize: "12px",
transform: resourcesCollapsed ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.2s ease",
display: "inline-block",
}}>
</span>
{!resourcesCollapsed && (
<span style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.1em", whiteSpace: "nowrap" }}>
Ressources
</span>
)}
</button>
{/* List */}
{!resourcesCollapsed && (
<div className="with-scrollbar" style={{ flex: 1, overflowY: "auto", padding: "10px 14px" }}>
{resources.map((r, i) => {
const owned = resourceInventory[r.name] ?? 0;
const done = owned >= r.quantity;
return (
<div key={i} style={{
padding: "6px 0", borderBottom: "1px solid #1f2937", fontSize: "12px",
}}>
<span style={{
color: done ? "#4ade80" : "#94a3b8",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
wordBreak: "break-word",
marginBottom: "3px",
} as React.CSSProperties}>
{r.name}
</span>
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<input
type="number"
min="0"
value={owned === 0 ? "" : owned}
placeholder="0"
onChange={e => {
const v = parseInt(e.target.value);
setResourceQuantity(r.name, isNaN(v) ? 0 : Math.max(0, v));
}}
style={{
width: "42px", background: "#0d1117",
border: `1px solid ${done ? "rgba(74,222,128,0.4)" : "#2d3748"}`,
borderRadius: "4px", padding: "2px 4px",
color: done ? "#4ade80" : "#e2e8f0",
fontSize: "11px", outline: "none", textAlign: "right",
}}
/>
<span style={{ color: done ? "#4ade80" : "#f0c040", fontWeight: 700, flexShrink: 0 }}>
/ ×{r.quantity}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
}
function Legend({ legend }: { legend: CombatType[] }) {
return (
<div style={{
marginBottom: "20px", padding: "12px 16px",
background: "rgba(255,255,255,0.02)", border: "1px solid #2d3748", borderRadius: "8px",
}}>
<div style={{
fontSize: "11px", fontWeight: 600, color: "#4a5568",
textTransform: "uppercase", letterSpacing: "0.1em", marginBottom: "10px",
}}>
Légende
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "16px" }}>
{legend.map((ct, i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<span style={{ fontSize: "15px" }}>{combatIcon(ct.name)}</span>
<span style={{ fontSize: "12px", color: "#94a3b8" }}>{ct.name}</span>
</div>
))}
</div>
</div>
);
}
function SectionItemView({ item, completedQuests, onToggle, onSelect }: {
item: SectionItem;
completedQuests: Set<string>;
onToggle: (name: string) => void;
onSelect: (quest: { name: string; url: string | null }) => void;
}) {
if (item.type === "Instruction") {
if (item.text.startsWith("__ZONE__:")) {
const zone = item.text.replace("__ZONE__:", "");
return (
<div style={{
display: "flex", alignItems: "center", gap: "8px",
padding: "8px 0 4px", marginBottom: "2px",
}}>
<div style={{ flex: 1, height: "1px", background: "#2d3748" }} />
<span style={{ fontSize: "11px", color: "#4a9eff", fontWeight: 600, flexShrink: 0 }}>{zone}</span>
<div style={{ flex: 1, height: "1px", background: "#2d3748" }} />
</div>
);
}
return (
<div style={{
marginBottom: "6px", padding: "9px 14px",
background: "rgba(74,158,255,0.04)",
borderLeft: "3px solid rgba(74,158,255,0.35)",
borderRadius: "4px",
fontSize: "12px", color: "#94a3b8", lineHeight: 1.6,
}}>
<span style={{ color: "#4a9eff", fontSize: "10px", fontWeight: 600, display: "block", marginBottom: "2px", textTransform: "uppercase", letterSpacing: "0.05em" }}>
Rappel
</span>
<TextWithCoords text={item.text} />
</div>
);
}
if (item.type === "Group") {
return (
<div style={{
background: "rgba(255,255,255,0.02)", border: "1px solid #2d3748",
borderRadius: "6px", padding: "8px 10px", marginBottom: "6px",
}}>
{item.note && (
<div style={{ fontSize: "11px", color: "#4a9eff", marginBottom: "6px", fontStyle: "italic" }}>
🔗 {item.note}
</div>
)}
{item.quests.map((q, i) => (
<QuestRow key={i} quest={q} indent completed={completedQuests.has(q.name)} onToggle={onToggle} onSelect={onSelect} />
))}
</div>
);
}
if (item.type === "Quest") {
return <QuestRow quest={item} completed={completedQuests.has(item.name)} onToggle={onToggle} onSelect={onSelect} />;
}
return null;
}
function QuestRow({ quest, completed, onToggle, onSelect, indent }: {
quest: QuestItem;
completed: boolean;
onToggle: (name: string) => void;
onSelect: (quest: { name: string; url: string | null }) => void;
indent?: boolean;
}) {
const questPreviews = useStore(s => s.questPreviews);
const previewsLoading = useStore(s => s.previewsLoading);
const [previewsOpen, setPreviewsOpen] = useState(false);
const previews = quest.url ? questPreviews[quest.url] : undefined;
const showLoadingPlaceholder = previewsLoading && quest.url !== null && previews === undefined;
const hasPreviewSection = showLoadingPlaceholder || (previews && previews.length > 0);
return (
<div
style={{
display: "flex", alignItems: "flex-start", gap: "8px",
padding: indent ? "3px 0" : "4px 6px",
borderRadius: "5px",
marginBottom: indent ? "1px" : "2px",
opacity: completed ? 0.5 : 1,
transition: "all 0.12s",
}}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.04)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<input
type="checkbox"
checked={completed}
onChange={() => onToggle(quest.name)}
style={{ marginTop: "2px", flexShrink: 0, cursor: "pointer" }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "baseline", flexWrap: "wrap", gap: "2px 8px" }}>
<span
onClick={() => onSelect({ name: quest.name, url: quest.url })}
style={{
fontSize: "12px", lineHeight: 1.4,
color: completed ? "#4a5568" : "#93c5fd",
textDecoration: completed ? "line-through" : "underline",
textDecorationColor: "rgba(147,197,253,0.3)",
cursor: "pointer",
wordBreak: "break-word",
}}
>
{quest.name}
</span>
{quest.combat_indicators.map((ci, i) => (
<span key={i} style={{ fontSize: "11px", color: "#94a3b8", whiteSpace: "nowrap", flexShrink: 0 }}>
{combatIcon(ci.combat_type)} x{ci.count}
</span>
))}
</div>
{hasPreviewSection && (
<span
onClick={() => setPreviewsOpen(o => !o)}
style={{
display: "inline-block",
marginTop: "2px",
fontSize: "10px",
color: "#4a5568",
cursor: "pointer",
userSelect: "none",
}}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.color = "#94a3b8"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.color = "#4a5568"; }}
>
{previewsOpen
? `▾ À prévoir`
: `▸ À prévoir${previews && previews.length > 0 ? ` (${previews.length})` : ""}`
}
</span>
)}
{previewsOpen && (
<>
{showLoadingPlaceholder && (
<div style={{
marginTop: "3px",
width: "60px", height: "14px",
borderRadius: "4px",
background: "linear-gradient(90deg, #1f2937 25%, #2d3748 50%, #1f2937 75%)",
backgroundSize: "200% 100%",
animation: "shimmer 1.4s infinite",
}} />
)}
{!showLoadingPlaceholder && previews && previews.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px", marginTop: "3px" }}>
{previews.map((ci, i) => (
<span key={i} style={{
display: "inline-flex", alignItems: "center", gap: "3px",
fontSize: "10px", padding: "1px 6px", borderRadius: "4px",
background: "rgba(255,255,255,0.05)", border: "1px solid #2d3748",
color: "#4a5568",
}}>
<span>{combatIcon(ci.combat_type)}</span>
<span>×{ci.count}{ci.combat_type !== "item" ? ` ${ci.combat_type}` : ""}</span>
{ci.evitable && (
<span style={{ color: "#4ade80" }}>(évit.)</span>
)}
{ci.label && (
<span style={{
fontStyle: "italic",
maxWidth: "80px", overflow: "hidden",
textOverflow: "ellipsis", whiteSpace: "nowrap",
display: "inline-block",
}}>
{ci.label}
</span>
)}
</span>
))}
</div>
)}
</>
)}
{quest.note && (
<div style={{ fontSize: "11px", color: "#4a5568", marginTop: "2px", fontStyle: "italic" }}>
<TextWithCoords text={quest.note} />
</div>
)}
</div>
</div>
);
}
function collectAllQuests(sections: import("../types").Section[]): string[] {
const names: string[] = [];
for (const section of sections) {
for (const item of section.items) {
if (item.type === "Quest") names.push(item.name);
else if (item.type === "Group") item.quests.forEach((q: QuestItem) => names.push(q.name));
}
}
return names;
}