feat: work on windows resizing
This commit is contained in:
@ -1,23 +1,51 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
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;
|
||||
}
|
||||
|
||||
function combatIcon(name: string): string {
|
||||
const l = name.toLowerCase();
|
||||
if (l.includes("solo")) return "⚔️";
|
||||
if (l.includes("group") || l.includes("groupe")) return "👥";
|
||||
if (l.includes("boss")) return "💀";
|
||||
if (l.includes("arène") || l.includes("arene")) return "🏟️";
|
||||
return "⚔️";
|
||||
if (l.includes("solo") || l.includes("seul")) return "🗡️";
|
||||
if (l.includes("group") || l.includes("groupe")) return "⚔️";
|
||||
if (l.includes("donjon") || l.includes("boss")) return "💀";
|
||||
return "🗡️";
|
||||
}
|
||||
|
||||
export default function GuideView() {
|
||||
const { activeGuideData, completedQuests, toggleQuest } = useStore();
|
||||
const [resourcesCollapsed, setResourcesCollapsed] = useState(false);
|
||||
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);
|
||||
@ -26,7 +54,7 @@ export default function GuideView() {
|
||||
const isDone = pct === 100;
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", overflow: "hidden", minHeight: 0 }}>
|
||||
<div style={{ flex: 1, display: "flex", overflow: "hidden", minHeight: 0, position: "relative" }}>
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "20px 24px" }}>
|
||||
|
||||
{/* Header */}
|
||||
@ -87,7 +115,7 @@ export default function GuideView() {
|
||||
{section.name}
|
||||
</h2>
|
||||
{section.items.map((item, ii) => (
|
||||
<SectionItemView key={ii} item={item} completedQuests={completedQuests} onToggle={toggleQuest} />
|
||||
<SectionItemView key={ii} item={item} completedQuests={completedQuests} onToggle={toggleQuest} onSelect={setSelectedQuest} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
@ -96,25 +124,41 @@ export default function GuideView() {
|
||||
{/* Resources panel */}
|
||||
{resources.length > 0 && (
|
||||
<div style={{
|
||||
width: resourcesCollapsed ? "32px" : "190px",
|
||||
flexShrink: 0, background: "#161b22",
|
||||
borderLeft: "1px solid #2d3748",
|
||||
display: "flex", flexDirection: "column",
|
||||
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",
|
||||
transition: "width 0.2s ease, background 0.2s ease",
|
||||
}}>
|
||||
{/* Toggle */}
|
||||
<button
|
||||
onClick={() => setResourcesCollapsed(c => !c)}
|
||||
onClick={() => setResourcesCollapsed(!resourcesCollapsed)}
|
||||
title={resourcesCollapsed ? "Afficher les ressources" : "Masquer les ressources"}
|
||||
style={{
|
||||
width: "100%", height: "36px", flexShrink: 0,
|
||||
background: "transparent", border: "none",
|
||||
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",
|
||||
color: "#4a5568",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: resourcesCollapsed ? "center" : "flex-start",
|
||||
padding: "0 10px", gap: "6px",
|
||||
padding: "0 10px",
|
||||
gap: "6px",
|
||||
marginTop: resourcesCollapsed ? "8px" : "0",
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = "#f0c040")}
|
||||
@ -138,15 +182,49 @@ export default function GuideView() {
|
||||
{/* List */}
|
||||
{!resourcesCollapsed && (
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "10px 14px", scrollbarWidth: "none" }}>
|
||||
{resources.map((r, i) => (
|
||||
<div key={i} style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
padding: "5px 0", borderBottom: "1px solid #1f2937", fontSize: "12px",
|
||||
}}>
|
||||
<span style={{ color: "#94a3b8", flex: 1, marginRight: "8px" }}>{r.name}</span>
|
||||
<span style={{ color: "#f0c040", fontWeight: 700, flexShrink: 0 }}>×{r.quantity}</span>
|
||||
</div>
|
||||
))}
|
||||
{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>
|
||||
@ -179,10 +257,11 @@ function Legend({ legend }: { legend: CombatType[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SectionItemView({ item, completedQuests, onToggle }: {
|
||||
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__:")) {
|
||||
@ -209,7 +288,7 @@ function SectionItemView({ item, completedQuests, onToggle }: {
|
||||
<span style={{ color: "#4a9eff", fontSize: "10px", fontWeight: 600, display: "block", marginBottom: "2px", textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
Rappel
|
||||
</span>
|
||||
{item.text}
|
||||
<TextWithCoords text={item.text} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -226,32 +305,32 @@ function SectionItemView({ item, completedQuests, onToggle }: {
|
||||
</div>
|
||||
)}
|
||||
{item.quests.map((q, i) => (
|
||||
<QuestRow key={i} quest={q} indent completed={completedQuests.has(q.name)} onToggle={onToggle} />
|
||||
<QuestRow key={i} quest={q} indent completed={completedQuests.has(q.name)} onToggle={onToggle} onSelect={onSelect} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "Quest") {
|
||||
return <QuestRow quest={item} completed={completedQuests.has(item.name)} onToggle={onToggle} />;
|
||||
return <QuestRow quest={item} completed={completedQuests.has(item.name)} onToggle={onToggle} onSelect={onSelect} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function QuestRow({ quest, completed, onToggle, indent }: {
|
||||
function QuestRow({ quest, completed, onToggle, onSelect, indent }: {
|
||||
quest: QuestItem;
|
||||
completed: boolean;
|
||||
onToggle: (name: string) => void;
|
||||
onSelect: (quest: { name: string; url: string | null }) => void;
|
||||
indent?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onToggle(quest.name)}
|
||||
style={{
|
||||
display: "flex", alignItems: "flex-start", gap: "8px",
|
||||
padding: indent ? "3px 0" : "4px 6px",
|
||||
borderRadius: "5px", cursor: "pointer",
|
||||
borderRadius: "5px",
|
||||
marginBottom: indent ? "1px" : "2px",
|
||||
opacity: completed ? 0.5 : 1,
|
||||
transition: "all 0.12s",
|
||||
@ -263,21 +342,19 @@ function QuestRow({ quest, completed, onToggle, indent }: {
|
||||
type="checkbox"
|
||||
checked={completed}
|
||||
onChange={() => onToggle(quest.name)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ marginTop: "2px", flexShrink: 0 }}
|
||||
style={{ marginTop: "2px", flexShrink: 0, cursor: "pointer" }}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", flexWrap: "wrap", gap: "2px 8px" }}>
|
||||
<span style={{
|
||||
fontSize: "12px", lineHeight: 1.4,
|
||||
color: completed ? "#4a5568" : quest.url ? "#93c5fd" : "#e2e8f0",
|
||||
textDecoration: completed ? "line-through" : quest.url ? "underline" : "none",
|
||||
textDecorationColor: "rgba(147,197,253,0.4)",
|
||||
cursor: quest.url ? "pointer" : "default",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
onClick={e => {
|
||||
if (quest.url) { e.stopPropagation(); openUrl(quest.url); }
|
||||
<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}
|
||||
@ -290,7 +367,7 @@ function QuestRow({ quest, completed, onToggle, indent }: {
|
||||
</div>
|
||||
{quest.note && (
|
||||
<div style={{ fontSize: "11px", color: "#4a5568", marginTop: "2px", fontStyle: "italic" }}>
|
||||
→ {quest.note}
|
||||
→ <TextWithCoords text={quest.note} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
272
src/components/QuestDetailPanel.tsx
Normal file
272
src/components/QuestDetailPanel.tsx
Normal file
@ -0,0 +1,272 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { QuestStep } from "../types";
|
||||
import { TextWithCoords } from "./TextWithCoords";
|
||||
|
||||
const PREVIEW_LENGTH = 280;
|
||||
|
||||
interface Props {
|
||||
questName: string;
|
||||
questUrl: string | null;
|
||||
profileId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function QuestDetailPanel({ questName, questUrl, profileId, onClose }: Props) {
|
||||
const [steps, setSteps] = useState<QuestStep[]>([]);
|
||||
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
|
||||
const [expandedSteps, setExpandedSteps] = useState<Set<number>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!questUrl) {
|
||||
setError("Aucun lien disponible pour cette quête.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
Promise.all([
|
||||
invoke<QuestStep[]>("fetch_quest_detail", { url: questUrl }),
|
||||
invoke<number[]>("get_completed_steps", { profileId, questName }),
|
||||
]).then(([fetchedSteps, completedIndices]) => {
|
||||
setSteps(fetchedSteps);
|
||||
setCompletedSteps(new Set(completedIndices));
|
||||
}).catch(e => {
|
||||
setError(`Impossible de charger la page : ${e}`);
|
||||
}).finally(() => setLoading(false));
|
||||
}, [questUrl, questName, profileId]);
|
||||
|
||||
const toggleStep = async (index: number) => {
|
||||
const isNow = await invoke<boolean>("toggle_quest_step", { profileId, questName, stepIndex: index });
|
||||
setCompletedSteps(prev => {
|
||||
const next = new Set(prev);
|
||||
if (isNow) next.add(index); else next.delete(index);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleExpanded = (index: number) => {
|
||||
setExpandedSteps(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(index)) next.delete(index); else next.add(index);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const firstIsHeader = steps.length > 0 && steps[0].launch_position != null;
|
||||
const headerStep = firstIsHeader ? steps[0] : null;
|
||||
const actionSteps = firstIsHeader ? steps.slice(1) : steps;
|
||||
const completedCount = actionSteps.filter(s => completedSteps.has(s.index)).length;
|
||||
const pct = actionSteps.length > 0 ? Math.round((completedCount / actionSteps.length) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", minHeight: 0 }}>
|
||||
{/* Title bar */}
|
||||
<div style={{
|
||||
padding: "12px 16px", borderBottom: "1px solid #2d3748",
|
||||
background: "#161b22", flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "10px", marginBottom: "6px" }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "transparent", border: "1px solid #2d3748",
|
||||
borderRadius: "5px", color: "#94a3b8", cursor: "pointer",
|
||||
fontSize: "11px", padding: "3px 8px", flexShrink: 0, transition: "all 0.15s",
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "#f0c040"; (e.currentTarget as HTMLElement).style.color = "#f0c040"; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "#2d3748"; (e.currentTarget as HTMLElement).style.color = "#94a3b8"; }}
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
<span style={{ fontSize: "13px", fontWeight: 600, color: "#e2e8f0", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{questName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{actionSteps.length > 0 && (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", fontSize: "11px", color: "#94a3b8", marginBottom: "4px" }}>
|
||||
<span>{completedCount}/{actionSteps.length} étapes</span>
|
||||
<span style={{ color: pct === 100 ? "#4ade80" : "#f0c040", fontWeight: 600 }}>{pct}%</span>
|
||||
</div>
|
||||
<div style={{ height: "3px", background: "#2d3748", borderRadius: "2px", overflow: "hidden" }}>
|
||||
<div style={{
|
||||
height: "100%", width: `${pct}%`,
|
||||
background: pct === 100 ? "#4ade80" : "linear-gradient(90deg, #4a9eff, #f0c040)",
|
||||
transition: "width 0.3s ease", borderRadius: "2px",
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{questUrl && (
|
||||
<button
|
||||
onClick={() => openUrl(questUrl)}
|
||||
style={{
|
||||
marginTop: "6px", background: "transparent", border: "none",
|
||||
color: "#4a5568", fontSize: "10px", cursor: "pointer", padding: 0,
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = "#93c5fd")}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = "#4a5568")}
|
||||
>
|
||||
Ouvrir sur Dofus Pour Les Noobs ↗
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "12px 16px", scrollbarWidth: "none" }}>
|
||||
{loading && (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "120px", color: "#4a5568", fontSize: "13px" }}>
|
||||
<span style={{ animation: "spin 1s linear infinite", display: "inline-block", marginRight: "8px" }}>↻</span>
|
||||
Chargement…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: "12px", background: "rgba(248,113,113,0.08)",
|
||||
border: "1px solid rgba(248,113,113,0.2)", borderRadius: "6px",
|
||||
color: "#f87171", fontSize: "12px",
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && steps.length === 0 && (
|
||||
<div style={{ color: "#4a5568", fontSize: "12px", textAlign: "center", paddingTop: "40px" }}>
|
||||
Aucune étape trouvée sur la page.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quest info header (first step) */}
|
||||
{!loading && headerStep && (
|
||||
<QuestHeader step={headerStep} />
|
||||
)}
|
||||
|
||||
{/* Action steps */}
|
||||
{!loading && actionSteps.map((step) => {
|
||||
const done = completedSteps.has(step.index);
|
||||
const expanded = expandedSteps.has(step.index);
|
||||
const needsTruncate = step.text.length > PREVIEW_LENGTH;
|
||||
const displayText = needsTruncate && !expanded
|
||||
? step.text.slice(0, PREVIEW_LENGTH).trimEnd() + "…"
|
||||
: step.text;
|
||||
|
||||
return (
|
||||
<div key={step.index} style={{
|
||||
marginBottom: "8px",
|
||||
background: done ? "rgba(74,222,128,0.04)" : "rgba(255,255,255,0.02)",
|
||||
border: `1px solid ${done ? "rgba(74,222,128,0.2)" : "#2d3748"}`,
|
||||
borderRadius: "7px", padding: "10px 12px",
|
||||
opacity: done ? 0.65 : 1, transition: "all 0.15s",
|
||||
}}>
|
||||
<div style={{ display: "flex", gap: "10px", alignItems: "flex-start" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={done}
|
||||
onChange={() => toggleStep(step.index)}
|
||||
style={{ marginTop: "2px", flexShrink: 0 }}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: "12px", color: done ? "#4a5568" : "#94a3b8",
|
||||
lineHeight: 1.6, whiteSpace: "pre-wrap", wordBreak: "break-word",
|
||||
textDecoration: done ? "line-through" : "none",
|
||||
}}>
|
||||
<TextWithCoords text={displayText} />
|
||||
</div>
|
||||
|
||||
{needsTruncate && (
|
||||
<button
|
||||
onClick={() => toggleExpanded(step.index)}
|
||||
style={{
|
||||
marginTop: "4px", background: "transparent", border: "none",
|
||||
color: "#4a9eff", fontSize: "11px", cursor: "pointer",
|
||||
padding: 0, textDecoration: "underline",
|
||||
}}
|
||||
>
|
||||
{expanded ? "Voir moins" : "Voir plus"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{step.images.length > 0 && (
|
||||
<div style={{ marginTop: "6px", display: "flex", flexWrap: "wrap", gap: "4px" }}>
|
||||
{step.images.map((src, j) => (
|
||||
<button
|
||||
key={j}
|
||||
onClick={() => openUrl(src)}
|
||||
style={{
|
||||
background: "rgba(74,158,255,0.08)", border: "1px solid rgba(74,158,255,0.25)",
|
||||
borderRadius: "4px", color: "#4a9eff", fontSize: "10px",
|
||||
padding: "2px 8px", cursor: "pointer",
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = "rgba(74,158,255,0.18)")}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = "rgba(74,158,255,0.08)")}
|
||||
>
|
||||
🖼 Image {j + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestHeader({ step }: { step: QuestStep }) {
|
||||
return (
|
||||
<div style={{
|
||||
marginBottom: "14px",
|
||||
border: "1px solid #2d3748",
|
||||
borderRadius: "8px",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<div style={{ padding: "10px 14px", display: "flex", alignItems: "baseline", gap: "8px", fontSize: "12px" }}>
|
||||
<span style={{ fontSize: "13px", flexShrink: 0 }}>📍</span>
|
||||
<span style={{
|
||||
color: "#4a5568", fontWeight: 600, fontSize: "10px",
|
||||
textTransform: "uppercase", letterSpacing: "0.05em",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
Position
|
||||
</span>
|
||||
<span style={{ color: "#cbd5e1", lineHeight: 1.5, wordBreak: "break-word" }}>
|
||||
<TextWithCoords text={step.launch_position!} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{step.images.length > 0 && (
|
||||
<div style={{ padding: "0 14px 10px", display: "flex", flexWrap: "wrap", gap: "4px" }}>
|
||||
{step.images.map((src, j) => (
|
||||
<button
|
||||
key={j}
|
||||
onClick={() => openUrl(src)}
|
||||
style={{
|
||||
background: "rgba(240,192,64,0.08)", border: "1px solid rgba(240,192,64,0.2)",
|
||||
borderRadius: "4px", color: "#f0c040", fontSize: "10px",
|
||||
padding: "2px 8px", cursor: "pointer",
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = "rgba(240,192,64,0.15)")}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = "rgba(240,192,64,0.08)")}
|
||||
>
|
||||
🖼 Image {j + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
204
src/components/SettingsPanel.tsx
Normal file
204
src/components/SettingsPanel.tsx
Normal file
@ -0,0 +1,204 @@
|
||||
import { useState } from "react";
|
||||
import { useStore } from "../store";
|
||||
|
||||
export default function SettingsPanel({ onClose }: { onClose: () => void }) {
|
||||
const {
|
||||
profiles, activeProfileId, setActiveProfile, createProfile, deleteProfile,
|
||||
syncGuides, syncing, syncProgress,
|
||||
} = useStore();
|
||||
|
||||
const [newName, setNewName] = useState("");
|
||||
const [profileError, setProfileError] = useState("");
|
||||
const [syncErrors, setSyncErrors] = useState<string[]>([]);
|
||||
const [syncDone, setSyncDone] = useState(false);
|
||||
|
||||
async function handleCreate() {
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
if (profiles.find(p => p.name === name)) {
|
||||
setProfileError("Un profil avec ce nom existe déjà.");
|
||||
return;
|
||||
}
|
||||
await createProfile(name);
|
||||
setNewName("");
|
||||
setProfileError("");
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (profiles.length <= 1) {
|
||||
setProfileError("Vous ne pouvez pas supprimer le dernier profil.");
|
||||
return;
|
||||
}
|
||||
await deleteProfile(id);
|
||||
}
|
||||
|
||||
async function handleSync() {
|
||||
setSyncErrors([]);
|
||||
setSyncDone(false);
|
||||
const result = await syncGuides();
|
||||
setSyncErrors(result.errors);
|
||||
setSyncDone(true);
|
||||
}
|
||||
|
||||
const { current = 0, total = 0, label = "" } = syncProgress ?? {};
|
||||
const syncPct = total > 0 ? Math.round((current / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: "fixed", inset: "40px 0 0 0",
|
||||
background: "#0d1117", zIndex: 50,
|
||||
display: "flex", flexDirection: "column", overflow: "hidden",
|
||||
borderTop: "1px solid #2d3748",
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
padding: "14px 20px", borderBottom: "1px solid #2d3748", flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ fontSize: "14px", fontWeight: 700, color: "#f0c040" }}>Paramètres</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{ background: "none", border: "none", color: "#94a3b8", cursor: "pointer", fontSize: "16px", lineHeight: 1 }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "20px", display: "flex", flexDirection: "column", gap: "28px", scrollbarWidth: "none" }}>
|
||||
|
||||
{/* ── Profils ── */}
|
||||
<section>
|
||||
<SectionTitle>Profils</SectionTitle>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px", marginBottom: "12px" }}>
|
||||
{profiles.map(profile => {
|
||||
const isActive = profile.id === activeProfileId;
|
||||
return (
|
||||
<div key={profile.id} style={{
|
||||
display: "flex", alignItems: "center", gap: "8px",
|
||||
background: isActive ? "rgba(240,192,64,0.07)" : "#161b22",
|
||||
border: `1px solid ${isActive ? "rgba(240,192,64,0.35)" : "#2d3748"}`,
|
||||
borderRadius: "8px", padding: "9px 12px",
|
||||
}}>
|
||||
<button
|
||||
onClick={() => { setActiveProfile(profile.id); setProfileError(""); }}
|
||||
style={{ flex: 1, background: "none", border: "none", textAlign: "left", cursor: "pointer" }}
|
||||
>
|
||||
<div style={{ fontSize: "13px", fontWeight: 600, color: isActive ? "#f0c040" : "#e2e8f0" }}>
|
||||
{isActive && "✓ "}{profile.name}
|
||||
</div>
|
||||
<div style={{ fontSize: "10px", color: "#4a5568", marginTop: "2px" }}>
|
||||
Créé le {new Date(profile.created_at).toLocaleDateString("fr-FR")}
|
||||
</div>
|
||||
</button>
|
||||
{profiles.length > 1 && (
|
||||
<button
|
||||
onClick={() => handleDelete(profile.id)}
|
||||
title="Supprimer ce profil"
|
||||
style={{ background: "none", border: "none", color: "#f87171", cursor: "pointer", padding: "4px", borderRadius: "4px", fontSize: "12px" }}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<input
|
||||
value={newName}
|
||||
onChange={e => { setNewName(e.target.value); setProfileError(""); }}
|
||||
onKeyDown={e => e.key === "Enter" && handleCreate()}
|
||||
placeholder="Nouveau profil…"
|
||||
style={{
|
||||
flex: 1, background: "#161b22", border: "1px solid #2d3748",
|
||||
borderRadius: "6px", padding: "7px 10px", color: "#e2e8f0",
|
||||
fontSize: "12px", outline: "none",
|
||||
}}
|
||||
onFocus={e => (e.target.style.borderColor = "#f0c040")}
|
||||
onBlur={e => (e.target.style.borderColor = "#2d3748")}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!newName.trim()}
|
||||
style={{
|
||||
background: "#f0c040", color: "#0d1117", border: "none",
|
||||
borderRadius: "6px", padding: "7px 14px", fontWeight: 700,
|
||||
fontSize: "12px", cursor: newName.trim() ? "pointer" : "default",
|
||||
opacity: newName.trim() ? 1 : 0.4, flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Créer
|
||||
</button>
|
||||
</div>
|
||||
{profileError && <p style={{ fontSize: "11px", color: "#f87171", marginTop: "6px" }}>{profileError}</p>}
|
||||
</section>
|
||||
|
||||
{/* ── Synchronisation ── */}
|
||||
<section>
|
||||
<SectionTitle>Synchronisation</SectionTitle>
|
||||
|
||||
<p style={{ fontSize: "12px", color: "#4a5568", marginBottom: "12px", lineHeight: 1.5 }}>
|
||||
Met à jour tous les guides depuis Google Sheets.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
style={{
|
||||
width: "100%", padding: "9px", borderRadius: "7px",
|
||||
background: syncing ? "rgba(74,158,255,0.08)" : "rgba(74,158,255,0.12)",
|
||||
border: "1px solid rgba(74,158,255,0.3)",
|
||||
color: syncing ? "#4a5568" : "#4a9eff",
|
||||
fontSize: "13px", fontWeight: 600, cursor: syncing ? "default" : "pointer",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", gap: "8px",
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "inline-block", animation: syncing ? "spin 1s linear infinite" : "none" }}>↻</span>
|
||||
{syncing ? "Synchronisation…" : "Synchroniser maintenant"}
|
||||
</button>
|
||||
|
||||
{syncing && syncProgress && (
|
||||
<div style={{ marginTop: "12px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", fontSize: "11px", color: "#94a3b8", marginBottom: "6px" }}>
|
||||
<span style={{ color: "#f0c040" }}>{label}</span>
|
||||
<span>{current}/{total} — {syncPct}%</span>
|
||||
</div>
|
||||
<div style={{ height: "3px", background: "#2d3748", borderRadius: "2px", overflow: "hidden" }}>
|
||||
<div style={{
|
||||
height: "100%", width: `${syncPct}%`,
|
||||
background: "linear-gradient(90deg, #4a9eff, #f0c040)",
|
||||
transition: "width 0.3s ease", borderRadius: "2px",
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{syncDone && !syncing && (
|
||||
<div style={{ marginTop: "10px", fontSize: "12px", color: syncErrors.length === 0 ? "#4ade80" : "#f87171" }}>
|
||||
{syncErrors.length === 0
|
||||
? "✓ Synchronisation terminée."
|
||||
: `⚠ ${syncErrors.length} erreur(s) :\n${syncErrors.join("\n")}`}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
fontSize: "10px", fontWeight: 700, color: "#4a5568",
|
||||
textTransform: "uppercase", letterSpacing: "0.1em",
|
||||
marginBottom: "10px", paddingBottom: "6px",
|
||||
borderBottom: "1px solid #2d3748",
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,23 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useStore } from "../store";
|
||||
|
||||
function useWindowWidth() {
|
||||
const [width, setWidth] = useState(window.innerWidth);
|
||||
useEffect(() => {
|
||||
const handler = () => setWidth(window.innerWidth);
|
||||
window.addEventListener("resize", handler);
|
||||
return () => window.removeEventListener("resize", handler);
|
||||
}, []);
|
||||
return width;
|
||||
}
|
||||
|
||||
export default function Sidebar() {
|
||||
const { guides, openGuide, activeGuideGid, view } = useStore();
|
||||
const { guides, openGuide, activeGuideGid, view, sidebarCollapsed, setSidebarCollapsed } = useStore();
|
||||
const [search, setSearch] = useState("");
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const collapsed = sidebarCollapsed;
|
||||
const setCollapsed = setSidebarCollapsed;
|
||||
const windowWidth = useWindowWidth();
|
||||
const isOverlay = collapsed || windowWidth < 500;
|
||||
|
||||
const filtered = guides.filter(g =>
|
||||
g.name.toLowerCase().includes(search.toLowerCase())
|
||||
@ -12,27 +25,40 @@ export default function Sidebar() {
|
||||
|
||||
return (
|
||||
<aside style={{
|
||||
width: collapsed ? "32px" : "220px",
|
||||
position: isOverlay ? "absolute" : "relative",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
zIndex: 10,
|
||||
width: collapsed ? "36px" : "190px",
|
||||
flexShrink: 0,
|
||||
background: "#161b22",
|
||||
borderRight: "1px solid #2d3748",
|
||||
background: collapsed ? "transparent" : "#161b22",
|
||||
borderRight: collapsed ? "none" : "1px solid #2d3748",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
transition: "width 0.2s ease",
|
||||
transition: "width 0.2s ease, background 0.2s ease",
|
||||
}}>
|
||||
{/* Toggle button */}
|
||||
<button
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
title={collapsed ? "Ouvrir le menu" : "Réduire le menu"}
|
||||
style={{
|
||||
width: "100%", height: "36px", flexShrink: 0,
|
||||
background: "transparent", border: "none",
|
||||
borderBottom: "1px solid #2d3748",
|
||||
color: "#4a5568", cursor: "pointer",
|
||||
display: "flex", alignItems: "center",
|
||||
width: "100%",
|
||||
height: "36px",
|
||||
flexShrink: 0,
|
||||
background: collapsed ? "rgba(22,27,34,0.9)" : "transparent",
|
||||
border: collapsed ? "1px solid #2d3748" : "none",
|
||||
borderLeft: "none",
|
||||
borderBottom: collapsed ? "1px solid #2d3748" : "1px solid #2d3748",
|
||||
borderRadius: collapsed ? "0 6px 6px 0" : "0",
|
||||
color: "#4a5568",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: collapsed ? "center" : "flex-end",
|
||||
padding: "0 10px",
|
||||
marginTop: collapsed ? "8px" : "0",
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = "#f0c040")}
|
||||
@ -59,7 +85,7 @@ export default function Sidebar() {
|
||||
style={{
|
||||
width: "100%", background: "#0d1117", border: "1px solid #2d3748",
|
||||
borderRadius: "6px", padding: "6px 10px", color: "#e2e8f0",
|
||||
fontSize: "12px", outline: "none",
|
||||
fontSize: "12px", outline: "none", boxSizing: "border-box",
|
||||
}}
|
||||
onFocus={e => (e.target.style.borderColor = "#f0c040")}
|
||||
onBlur={e => (e.target.style.borderColor = "#2d3748")}
|
||||
@ -96,13 +122,18 @@ export default function Sidebar() {
|
||||
if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "4px" }}>
|
||||
<span style={{
|
||||
fontSize: "12px", fontWeight: isActive ? 600 : 400,
|
||||
color: isActive ? "#f0c040" : "#e2e8f0",
|
||||
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
||||
maxWidth: "140px",
|
||||
}}>
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
wordBreak: "break-word",
|
||||
lineHeight: 1.3,
|
||||
flex: 1,
|
||||
} as React.CSSProperties}>
|
||||
{guide.name}
|
||||
</span>
|
||||
<span style={{
|
||||
|
||||
53
src/components/TextWithCoords.tsx
Normal file
53
src/components/TextWithCoords.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { useState } from "react";
|
||||
|
||||
const COORD_RE = /\[(-?\d+),\s*(-?\d+)\]/g;
|
||||
|
||||
export function TextWithCoords({ text, style }: { text: string; style?: React.CSSProperties }) {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let last = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
COORD_RE.lastIndex = 0;
|
||||
while ((match = COORD_RE.exec(text)) !== null) {
|
||||
if (match.index > last) parts.push(text.slice(last, match.index));
|
||||
parts.push(<CoordBadge key={match.index} x={match[1]} y={match[2]} raw={match[0]} />);
|
||||
last = match.index + match[0].length;
|
||||
}
|
||||
if (last < text.length) parts.push(text.slice(last));
|
||||
|
||||
return <span style={style}>{parts}</span>;
|
||||
}
|
||||
|
||||
function CoordBadge({ x, y, raw }: { x: string; y: string; raw: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleClick(e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
await navigator.clipboard.writeText(`/travel ${x},${y}`);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={handleClick}
|
||||
title={`Copier /travel ${x},${y}`}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
background: copied ? "rgba(74,222,128,0.15)" : "rgba(74,158,255,0.1)",
|
||||
border: `1px solid ${copied ? "rgba(74,222,128,0.4)" : "rgba(74,158,255,0.3)"}`,
|
||||
borderRadius: "3px",
|
||||
padding: "0 5px",
|
||||
color: copied ? "#4ade80" : "#93c5fd",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.85em",
|
||||
fontFamily: "monospace",
|
||||
userSelect: "none",
|
||||
transition: "background 0.15s, color 0.15s, border-color 0.15s",
|
||||
verticalAlign: "baseline",
|
||||
}}
|
||||
>
|
||||
{copied ? "✓ copié" : raw}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -2,14 +2,14 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useStore } from "../store";
|
||||
|
||||
interface Props {
|
||||
onOpenProfiles: () => void;
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
export default function TitleBar({ onOpenProfiles }: Props) {
|
||||
const { alwaysOnTop, toggleAlwaysOnTop, syncing, syncGuides, view, closeGuide, activeGuideData } = useStore();
|
||||
export default function TitleBar({ onOpenSettings }: Props) {
|
||||
const { view, closeGuide, activeGuideData } = useStore();
|
||||
|
||||
function handleDragMouseDown(e: React.MouseEvent) {
|
||||
if (e.button === 0 && !alwaysOnTop) {
|
||||
if (e.button === 0) {
|
||||
getCurrentWindow().startDragging();
|
||||
}
|
||||
}
|
||||
@ -36,7 +36,7 @@ export default function TitleBar({ onOpenProfiles }: Props) {
|
||||
onMouseDown={handleDragMouseDown}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: "8px", flex: 1,
|
||||
cursor: alwaysOnTop ? "default" : "grab", userSelect: "none",
|
||||
cursor: "grab", userSelect: "none",
|
||||
}}
|
||||
>
|
||||
<img src="/logo_tougli.png" style={{ pointerEvents: "none", width: "24px", height: "24px", objectFit: "contain" }} />
|
||||
@ -59,24 +59,8 @@ export default function TitleBar({ onOpenProfiles }: Props) {
|
||||
</TitleButton>
|
||||
)}
|
||||
|
||||
<TitleButton onClick={onOpenProfiles} title="Gérer les profils">
|
||||
👤
|
||||
</TitleButton>
|
||||
|
||||
<TitleButton
|
||||
onClick={syncGuides}
|
||||
title="Synchroniser avec Google Sheets"
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing ? <SpinIcon /> : "↻"}
|
||||
</TitleButton>
|
||||
|
||||
<TitleButton
|
||||
onClick={toggleAlwaysOnTop}
|
||||
title={alwaysOnTop ? "Désactiver fenêtre flottante" : "Activer fenêtre flottante"}
|
||||
active={alwaysOnTop}
|
||||
>
|
||||
📌
|
||||
<TitleButton onClick={onOpenSettings} title="Paramètres">
|
||||
⚙
|
||||
</TitleButton>
|
||||
|
||||
<div style={{ width: "1px", height: "16px", background: "#2d3748", margin: "0 4px" }} />
|
||||
@ -134,8 +118,3 @@ function TitleButton({
|
||||
);
|
||||
}
|
||||
|
||||
function SpinIcon() {
|
||||
return (
|
||||
<span style={{ display: "inline-block", animation: "spin 1s linear infinite" }}>↻</span>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user