From 7a6eb389d97bd8e06839d094a0130e9f4257d39f Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Sat, 20 Dec 2025 20:16:18 +0000 Subject: [PATCH] feat: tutorial improvements - refactored - preload images - also changed robots.txt --- src/app/robots.ts | 1 - src/components/tutorial/index.tsx | 215 ++++++++++++++++++++++ src/components/tutorial/page.tsx | 59 ------ src/components/tutorial/scan.tsx | 100 ++-------- src/components/tutorial/starting-page.tsx | 61 ------ src/components/tutorial/submit.tsx | 155 +++++----------- 6 files changed, 278 insertions(+), 313 deletions(-) create mode 100644 src/components/tutorial/index.tsx delete mode 100644 src/components/tutorial/page.tsx delete mode 100644 src/components/tutorial/starting-page.tsx diff --git a/src/app/robots.ts b/src/app/robots.ts index 3e6c090..e05c087 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -16,7 +16,6 @@ export default function robots(): MetadataRoute.Robots { "/report/mii/*", "/report/user/*", "/admin", - "/_next/image", ], }, sitemap: `${process.env.NEXT_PUBLIC_BASE_URL}/sitemap.xml`, diff --git a/src/components/tutorial/index.tsx b/src/components/tutorial/index.tsx new file mode 100644 index 0000000..74a610c --- /dev/null +++ b/src/components/tutorial/index.tsx @@ -0,0 +1,215 @@ +"use client"; + +import Image from "next/image"; +import { useEffect, useState } from "react"; +import useEmblaCarousel from "embla-carousel-react"; +import { Icon } from "@iconify/react"; +import confetti from "canvas-confetti"; +import ReturnToIsland from "../admin/return-to-island"; + +interface Slide { + // step is never used, undefined is assumed as a step + type?: "start" | "step" | "finish"; + text?: string; + imageSrc?: string; +} + +interface Tutorial { + title: string; + thumbnail?: string; + hint?: string; + steps: Slide[]; +} + +interface Props { + tutorials: Tutorial[]; + isOpen: boolean; + setIsOpen: React.Dispatch>; +} + +export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) { + const [isVisible, setIsVisible] = useState(false); + + const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }); + const [selectedIndex, setSelectedIndex] = useState(0); + + // Build index map + const slides: Array = []; + const startSlides: Record = {}; + + tutorials.forEach((tutorial) => { + tutorial.steps.forEach((slide) => { + if (slide.type === "start") { + startSlides[tutorial.title] = slides.length; + } + slides.push({ ...slide, tutorialTitle: tutorial.title }); + }); + }); + + const currentSlide = slides[selectedIndex]; + const isStartingPage = currentSlide?.type === "start"; + + useEffect(() => { + if (currentSlide.type !== "finish") return; + + const defaults = { startVelocity: 30, spread: 360, ticks: 120, zIndex: 50 }; + const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min; + + setTimeout(() => { + confetti({ + ...defaults, + particleCount: 500, + origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }, + }); + confetti({ + ...defaults, + particleCount: 500, + origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }, + }); + }, 300); + }, [currentSlide]); + + const close = () => { + setIsVisible(false); + setTimeout(() => { + setIsOpen(false); + setSelectedIndex(0); + }, 300); + }; + + const goToTutorial = (tutorialTitle: string) => { + if (!emblaApi) return; + const index = startSlides[tutorialTitle]; + + // Jump to next starting slide then transition to actual tutorial + emblaApi.scrollTo(index, true); + emblaApi.scrollTo(index + 1); + }; + + useEffect(() => { + if (isOpen) { + // slight delay to trigger animation + setTimeout(() => setIsVisible(true), 10); + } + }, [isOpen]); + + useEffect(() => { + if (!emblaApi) return; + emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap())); + }, [emblaApi]); + + return ( +
+
+ +
+
+

Tutorial

+ +
+ +
+
+
+ {slides.map((slide, index) => ( +
+ {slide.type === "start" ? ( + <> + {/* Separator */} +
+
+ Pick a tutorial +
+
+ +
+ {tutorials.map((tutorial, tutorialIndex) => ( + + ))} +
+ + ) : slide.type === "finish" ? ( +
+ +

Yatta! You did it!

+
+ ) : ( + <> +

{slide.text}

+ + step image + + )} +
+ ))} +
+
+ + {/* Arrows */} +
+ + + {/* Only show tutorial name on step slides */} + + {currentSlide?.tutorialTitle} + + + +
+
+
+
+ ); +} diff --git a/src/components/tutorial/page.tsx b/src/components/tutorial/page.tsx deleted file mode 100644 index 7b8f445..0000000 --- a/src/components/tutorial/page.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import Image from "next/image"; -import { Icon } from "@iconify/react"; -import { useEffect } from "react"; - -import confetti from "canvas-confetti"; - -interface Props { - text?: string; - imageSrc?: string; - carouselIndex?: number; - finishIndex?: number; -} - -export default function TutorialPage({ text, imageSrc, carouselIndex, finishIndex }: Props) { - useEffect(() => { - if (carouselIndex !== finishIndex || !carouselIndex || !finishIndex) return; - - const defaults = { startVelocity: 30, spread: 360, ticks: 120, zIndex: 50 }; - const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min; - - setTimeout(() => { - confetti({ - ...defaults, - particleCount: 500, - origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }, - }); - confetti({ - ...defaults, - particleCount: 500, - origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }, - }); - }, 300); - }, [carouselIndex, finishIndex]); - - return ( -
- {!finishIndex ? ( - <> -

{text}

- - step image - - ) : ( -
- -

Yatta! You did it!

-
- )} -
- ); -} diff --git a/src/components/tutorial/scan.tsx b/src/components/tutorial/scan.tsx index 6bcbf6a..5676c15 100644 --- a/src/components/tutorial/scan.tsx +++ b/src/components/tutorial/scan.tsx @@ -1,38 +1,13 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { createPortal } from "react-dom"; -import useEmblaCarousel from "embla-carousel-react"; import { Icon } from "@iconify/react"; -import TutorialPage from "./page"; +import Tutorial from "."; export default function ScanTutorialButton() { const [isOpen, setIsOpen] = useState(false); - const [isVisible, setIsVisible] = useState(false); - - const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }); - const [selectedIndex, setSelectedIndex] = useState(0); - - const close = () => { - setIsVisible(false); - setTimeout(() => { - setIsOpen(false); - setSelectedIndex(0); - }, 300); - }; - - useEffect(() => { - if (isOpen) { - // slight delay to trigger animation - setTimeout(() => setIsVisible(true), 10); - } - }, [isOpen]); - - useEffect(() => { - if (!emblaApi) return; - emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap())); - }, [emblaApi]); return ( <> @@ -43,60 +18,23 @@ export default function ScanTutorialButton() { {isOpen && createPortal( -
-
- -
-
-

Tutorial

- -
- -
-
-
- - - - - - -
-
- -
- - - Adding Mii to Island - - -
-
-
-
, + , document.body )} diff --git a/src/components/tutorial/starting-page.tsx b/src/components/tutorial/starting-page.tsx deleted file mode 100644 index 9cb739f..0000000 --- a/src/components/tutorial/starting-page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import Image from "next/image"; -import { UseEmblaCarouselType } from "embla-carousel-react"; - -interface Props { - emblaApi: UseEmblaCarouselType[1] | undefined; -} - -export default function StartingPage({ emblaApi }: Props) { - const goToTutorial = (index: number) => { - if (!emblaApi) return; - - emblaApi.scrollTo(index - 1, true); - emblaApi.scrollTo(index); - }; - - return ( -
- {/* Separator */} -
-
- Pick a tutorial -
-
- -
- - - -
-
- ); -} diff --git a/src/components/tutorial/submit.tsx b/src/components/tutorial/submit.tsx index 3f3389b..a7f5b1a 100644 --- a/src/components/tutorial/submit.tsx +++ b/src/components/tutorial/submit.tsx @@ -1,42 +1,11 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { createPortal } from "react-dom"; -import useEmblaCarousel from "embla-carousel-react"; -import { Icon } from "@iconify/react"; - -import TutorialPage from "./page"; -import StartingPage from "./starting-page"; +import Tutorial from "."; export default function SubmitTutorialButton() { const [isOpen, setIsOpen] = useState(false); - const [isVisible, setIsVisible] = useState(false); - - const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }); - const [selectedIndex, setSelectedIndex] = useState(0); - - const close = () => { - setIsVisible(false); - setTimeout(() => { - setIsOpen(false); - setSelectedIndex(0); - }, 300); - }; - - useEffect(() => { - if (isOpen) { - // slight delay to trigger animation - setTimeout(() => setIsVisible(true), 10); - } - }, [isOpen]); - - useEffect(() => { - if (!emblaApi) return; - emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap())); - }, [emblaApi]); - - const isStartingPage = selectedIndex === 0 || selectedIndex === 9; - const inTutorialAllowCopying = selectedIndex && selectedIndex >= 1 && selectedIndex <= 9; return ( <> @@ -46,84 +15,48 @@ export default function SubmitTutorialButton() { {isOpen && createPortal( -
-
- -
-
-

Tutorial

- -
- -
-
-
- - - {/* Allow Copying */} - - - - - - - - - - - - {/* Create QR Code */} - - - - - - - -
-
- -
- - - {inTutorialAllowCopying ? "Allow Copying" : "Create QR Code"} - - -
-
-
-
, + , document.body )}