diff --git a/public/images/tools/guides.png b/public/images/tools/guides.png new file mode 100644 index 0000000..69c8a62 Binary files /dev/null and b/public/images/tools/guides.png differ diff --git a/src/components/views/DevtoolsView.tsx b/src/components/views/DevtoolsView.tsx index 00b4fe7..0b9956f 100644 --- a/src/components/views/DevtoolsView.tsx +++ b/src/components/views/DevtoolsView.tsx @@ -10,6 +10,7 @@ interface DevTool { } const DEV_TOOLS: DevTool[] = [ + { id: "guides", name: "Community Guides", view: "guides", comingSoon: false }, { id: "pck", name: "PCK Editor", view: "pck-editor", comingSoon: false }, { id: "arc", name: "ARC Editor", view: "arc-editor", comingSoon: false }, { id: "loc", name: "LOC Editor", view: "loc-editor", comingSoon: false }, diff --git a/src/components/views/GuidesView.tsx b/src/components/views/GuidesView.tsx new file mode 100644 index 0000000..8291f46 --- /dev/null +++ b/src/components/views/GuidesView.tsx @@ -0,0 +1,284 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { motion } from "framer-motion"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; +import { useUI, useAudio, useConfig } from "../../context/LauncherContext"; +import { TauriService } from "../../services/TauriService"; +const API_BASE = "https://api.github.com/repos/LCE-Hub/Guides/contents"; +const RAW_BASE = "https://raw.githubusercontent.com/LCE-Hub/Guides/main"; +interface GuideEntry { + name: string; + path: string; + type: "file" | "dir"; + download_url: string | null; +} + +export default function GuidesView() { + const { setActiveView } = useUI(); + const { playPressSound, playBackSound } = useAudio(); + const { animationsEnabled } = useConfig(); + const [guides, setGuides] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedGuide, setSelectedGuide] = useState(null); + const [guideContent, setGuideContent] = useState(null); + const [contentLoading, setContentLoading] = useState(false); + const [focusIndex, setFocusIndex] = useState(0); + const containerRef = useRef(null); + const BACK_BUTTON_INDEX = guides.length; + useEffect(() => { + let cancelled = false; + setLoading(true); + fetch(API_BASE, { + headers: { Accept: "application/vnd.github.v3+json" }, + }) + .then((res) => { + if (!res.ok) throw new Error(`GitHub API error: ${res.status}`); + return res.json(); + }) + .then((data: GuideEntry[]) => { + if (!cancelled) { + const items = data.filter((e) => e.name.endsWith(".md")); + items.sort((a, b) => a.name.localeCompare(b.name)); + setGuides(items); + setError(null); + } + }) + .catch((err) => { + if (!cancelled) setError(err.message); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const fetchContent = useCallback(async (guide: GuideEntry) => { + if (guide.type === "dir") return; + setContentLoading(true); + setGuideContent(null); + setSelectedGuide(guide); + try { + const url = `${RAW_BASE}/${guide.path}`; + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to fetch guide: ${res.status}`); + const text = await res.text(); + setGuideContent(text); + } catch (err: any) { + setGuideContent(`**Error:** ${err.message}`); + } finally { + setContentLoading(false); + } + }, []); + + const goBack = useCallback(() => { + if (selectedGuide) { + setSelectedGuide(null); + setGuideContent(null); + } else { + playBackSound(); + setActiveView("devtools"); + } + }, [selectedGuide, playBackSound, setActiveView]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" || e.key === "Backspace") { + e.preventDefault(); + goBack(); + return; + } + if (selectedGuide) return; + if (e.key === "ArrowDown") { + setFocusIndex((p) => (p >= BACK_BUTTON_INDEX ? 0 : p + 1)); + } else if (e.key === "ArrowUp") { + setFocusIndex((p) => (p <= 0 ? BACK_BUTTON_INDEX : p - 1)); + } else if (e.key === "Enter") { + if (focusIndex === BACK_BUTTON_INDEX) { + goBack(); + } else { + playPressSound(); + fetchContent(guides[focusIndex]); + } + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [ + focusIndex, + guides, + selectedGuide, + goBack, + playPressSound, + BACK_BUTTON_INDEX, + ]); + + useEffect(() => { + const el = containerRef.current?.querySelector( + `[data-index="${focusIndex}"]`, + ) as HTMLElement; + if (el) el.focus(); + }, [focusIndex]); + + const renderContent = () => { + if (!selectedGuide) return null; + if (contentLoading) { + return ( +
+ + Loading guide... + +
+ ); + } + if (guideContent === null) return null; + return ( +
+ +

+ {selectedGuide.name.replace(/\.md$/i, "")} +

+ +
+ ); + }; + + return ( + +

+ {selectedGuide + ? selectedGuide.name.replace(/\.md$/i, "") + : "Community Guides"} +

+ +
+ {selectedGuide ? ( + renderContent() + ) : loading ? ( +
+ + Loading guides... + +
+ ) : error ? ( +
+ + Failed to load guides + + + {error} + +
+ ) : guides.length === 0 ? ( +
+ + No guides found + +
+ ) : ( +
+ {guides.map((guide, i) => ( +
setFocusIndex(i)} + onClick={() => { + if (guide.type === "dir") return; + playPressSound(); + fetchContent(guide); + }} + className={`flex flex-col items-center gap-3 p-5 cursor-pointer transition-all outline-none border-2 ${ + focusIndex === i + ? "border-[#FFFF55] bg-white/5" + : "border-[#373737] bg-black/20 hover:border-[#555]" + } ${guide.type === "dir" ? "opacity-60 pointer-events-none" : ""}`} + > +
+ +
+ + {guide.name.replace(/\.md$/i, "")} + +
+ ))} +
+ )} +
+ + +
+ ); +} diff --git a/src/components/views/SkinsView.tsx b/src/components/views/SkinsView.tsx index 79e089d..4cf2297 100644 --- a/src/components/views/SkinsView.tsx +++ b/src/components/views/SkinsView.tsx @@ -60,7 +60,7 @@ const DEFAULT_SKINS: SavedSkin[] = [ ]; const SkinsView = memo(function SkinsView() { - const { setActiveView } = useUI(); + const { setActiveView, setIsUiHidden } = useUI(); const { playPressSound, playBackSound } = useAudio(); const { skinUrl, setSkinUrl, setSkinIsSlim, capeUrl, setCapeUrl } = useSkin(); @@ -712,6 +712,7 @@ const SkinsView = memo(function SkinsView() { setSkinUrl={setSkinUrl} capeUrl={capeUrl} setActiveView={setActiveView} + setIsUiHidden={setIsUiHidden} isFocusedSection={false} onNavigateRight={() => {}} hideControls diff --git a/src/pages/App.tsx b/src/pages/App.tsx index cd49884..05c25e1 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -5,6 +5,7 @@ import HomeView from "../components/views/HomeView"; import SettingsView from "../components/views/SettingsView"; import VersionsView from "../components/views/VersionsView"; import DevtoolsView from "../components/views/DevtoolsView"; +import GuidesView from "../components/views/GuidesView"; import SkinsView from "../components/views/SkinsView"; import WorkshopView from "../components/views/WorkshopView"; import SetupView from "../components/views/SetupView"; @@ -585,6 +586,9 @@ export default function App() { {activeView === "devtools" && ( )} + {activeView === "guides" && ( + + )} {activeView === "pck-editor" && ( )}