mirror of
https://github.com/stefanpejcic/openpanel
synced 2025-06-26 18:28:26 +00:00
529 lines
16 KiB
TypeScript
529 lines
16 KiB
TypeScript
import React from "react";
|
|
import clsx from "clsx";
|
|
import { ArrowRightIcon } from "./icons/arrow-right";
|
|
import { RedoIcon } from "./icons/redo";
|
|
import { AnimatePresence, motion, useInView } from "framer-motion";
|
|
import Highlight, { defaultProps } from "prism-react-renderer";
|
|
import { usePrismTheme } from "@docusaurus/theme-common";
|
|
|
|
const WalkthroughCodeWindow = ({ code }: { code: string }) => {
|
|
const theme = usePrismTheme();
|
|
return (
|
|
<div
|
|
className={clsx(
|
|
"w-full",
|
|
"min-w-[430px]",
|
|
"max-w-[430px]",
|
|
"rounded-lg",
|
|
"ring-4",
|
|
"ring-refine-bg",
|
|
"bg-gray-700",
|
|
"flex flex-col",
|
|
)}
|
|
>
|
|
<div
|
|
className={clsx(
|
|
"p-3",
|
|
"border-b",
|
|
"border-b-gray-800",
|
|
"flex items-center justify-start",
|
|
"gap-1",
|
|
)}
|
|
>
|
|
{["#FF5F5A", "#FFBE2E", "#2ACA44"].map((c) => (
|
|
<div
|
|
key={c}
|
|
className={clsx("rounded-full", "w-2", "h-2")}
|
|
style={{
|
|
backgroundColor: c,
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div
|
|
className={clsx(
|
|
"px-4",
|
|
"pb-12",
|
|
"pt-4",
|
|
"w-full",
|
|
"flex-1",
|
|
"font-mono",
|
|
"text-[12px] leading-[16px]",
|
|
"text-gray-0",
|
|
)}
|
|
>
|
|
<Highlight
|
|
{...defaultProps}
|
|
theme={theme}
|
|
code={`${code ?? ""}`.trim()}
|
|
language="tsx"
|
|
>
|
|
{({ tokens, getLineProps, getTokenProps }) => (
|
|
<>
|
|
{tokens.map((line, i) => (
|
|
<div
|
|
{...getLineProps({ line })}
|
|
key={`${code}-${i}`}
|
|
>
|
|
{line.map((token, key) => {
|
|
const content =
|
|
`${token.content}`.replace(
|
|
"___",
|
|
" ",
|
|
);
|
|
|
|
const {
|
|
children: _children,
|
|
...tokenProps
|
|
} = getTokenProps({
|
|
token,
|
|
});
|
|
|
|
return (
|
|
<span
|
|
{...tokenProps}
|
|
key={`${token.content}-${key}`}
|
|
dangerouslySetInnerHTML={{
|
|
__html: content,
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
</Highlight>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const WalkthroughNextButton = ({
|
|
onClick,
|
|
children,
|
|
}: {
|
|
onClick: () => void;
|
|
children: React.ReactNode;
|
|
}) => {
|
|
return (
|
|
<motion.button
|
|
type="button"
|
|
initial={{ opacity: 0, translateY: 30, rotateX: -90 }}
|
|
animate={{
|
|
opacity: 1,
|
|
rotateX: 0,
|
|
translateY: 0,
|
|
}}
|
|
exit={{ opacity: 0, rotateX: -90, translateY: 30 }}
|
|
transition={{
|
|
duration: 0.3,
|
|
delay: 0.4,
|
|
}}
|
|
className={clsx(
|
|
"-mt-6",
|
|
"ml-4",
|
|
"overflow-hidden",
|
|
"p-px",
|
|
"rounded-[8px]",
|
|
"relative",
|
|
"w-auto",
|
|
"flex",
|
|
"ring-4",
|
|
"ring-refine-bg",
|
|
"group",
|
|
"focus:outline-none",
|
|
"z-[1]",
|
|
)}
|
|
onClick={onClick}
|
|
>
|
|
<div
|
|
className={clsx(
|
|
"bg-walkthrough-button-shadow",
|
|
"w-[300px]",
|
|
"aspect-square",
|
|
"h-auto",
|
|
"absolute",
|
|
"-left-[calc(150px-50%)]",
|
|
"-top-[calc(150px-calc(48px/2))]",
|
|
"animate-spin-slow",
|
|
)}
|
|
/>
|
|
<div
|
|
className={clsx(
|
|
"py-3",
|
|
"px-5",
|
|
"bg-refine-walkthrough-button-bg",
|
|
"flex-shrink-0",
|
|
"flex",
|
|
"z-[1]",
|
|
"rounded-[7px]",
|
|
"text-gray-0",
|
|
"group-hover:brightness-110",
|
|
"transition-[filter]",
|
|
"duration-200",
|
|
"ease-in-out",
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
</motion.button>
|
|
);
|
|
};
|
|
|
|
const WalkthroughRestartButton = ({ onClick }: { onClick: () => void }) => {
|
|
return (
|
|
<motion.button
|
|
type="button"
|
|
className={clsx(
|
|
"-mt-6",
|
|
"ml-4",
|
|
"overflow-hidden",
|
|
"p-px",
|
|
"rounded-[8px]",
|
|
"relative",
|
|
"w-auto",
|
|
"flex",
|
|
"ring-4",
|
|
"ring-refine-bg",
|
|
"group",
|
|
"focus:outline-none",
|
|
"z-[1]",
|
|
)}
|
|
initial={{ opacity: 0, translateY: 30, rotateX: -90 }}
|
|
animate={{
|
|
opacity: 1,
|
|
rotateX: 0,
|
|
translateY: 0,
|
|
}}
|
|
exit={{ opacity: 0, rotateX: -90, translateY: 30 }}
|
|
transition={{
|
|
duration: 0.3,
|
|
delay: 0.4,
|
|
}}
|
|
onClick={onClick}
|
|
>
|
|
<div
|
|
className={clsx(
|
|
"bg-walkthrough-button-alt-shadow",
|
|
"w-[300px]",
|
|
"aspect-square",
|
|
"h-auto",
|
|
"absolute",
|
|
"-left-[calc(150px-50%)]",
|
|
"-top-[calc(150px-calc(48px/2))]",
|
|
"animate-spin-slow",
|
|
)}
|
|
/>
|
|
<div
|
|
className={clsx(
|
|
"py-3",
|
|
"px-5",
|
|
"bg-refine-walkthrough-button-alt-bg",
|
|
"flex-shrink-0",
|
|
"flex",
|
|
"z-[1]",
|
|
"rounded-[7px]",
|
|
"text-gray-0",
|
|
"group-hover:brightness-110",
|
|
"transition-[filter]",
|
|
"duration-200",
|
|
"ease-in-out",
|
|
)}
|
|
>
|
|
<span
|
|
className={clsx(
|
|
"whitespace-nowrap",
|
|
"flex items-center justify-center",
|
|
"gap-2",
|
|
"text-base",
|
|
"text-gray-0",
|
|
)}
|
|
>
|
|
<span>
|
|
<RedoIcon />
|
|
</span>
|
|
<span>
|
|
<span className="font-semibold">Start Over</span>
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</motion.button>
|
|
);
|
|
};
|
|
|
|
const WalkthroughNextButtonLabel = ({ text }: { text: string }) => {
|
|
return (
|
|
<span
|
|
className={clsx(
|
|
"whitespace-nowrap",
|
|
"flex items-center justify-center",
|
|
"gap-3",
|
|
"text-base",
|
|
"text-gray-0",
|
|
)}
|
|
>
|
|
<span>
|
|
<span className="font-normal">Next: </span>
|
|
<span className="font-semibold">{text}</span>
|
|
</span>
|
|
<span>
|
|
<ArrowRightIcon />
|
|
</span>
|
|
</span>
|
|
);
|
|
};
|
|
|
|
const WalkthroughPhaseWrapper = ({
|
|
onClick,
|
|
text,
|
|
code,
|
|
end,
|
|
}: {
|
|
onClick: () => void;
|
|
code: string;
|
|
text?: string;
|
|
end?: boolean;
|
|
}) => {
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 80, scale: 0.9 }}
|
|
animate={{
|
|
opacity: 1,
|
|
y: 0,
|
|
scale: 1,
|
|
}}
|
|
exit={{ opacity: 0, y: -80, scale: 0.9 }}
|
|
transition={{
|
|
duration: 0.23,
|
|
}}
|
|
className={clsx("absolute", "left-0", "bottom-0")}
|
|
>
|
|
<AnimatePresence>
|
|
<WalkthroughCodeWindow code={code} />
|
|
{end && <WalkthroughRestartButton onClick={onClick} />}
|
|
{text && (
|
|
<WalkthroughNextButton onClick={onClick}>
|
|
<WalkthroughNextButtonLabel text={text} />
|
|
</WalkthroughNextButton>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
);
|
|
};
|
|
|
|
const walkthroughPhases = [
|
|
"initial",
|
|
"add-your-backend",
|
|
"hook-the-table",
|
|
"add-your-ui-framework",
|
|
"add-more-crud-functions",
|
|
"add-more-routes",
|
|
"secure-your-app",
|
|
"restart",
|
|
] as const;
|
|
|
|
type WalkthroughPhase = (typeof walkthroughPhases)[number];
|
|
|
|
const phaseCheckpoints: Record<(typeof walkthroughPhases)[number], number> = {
|
|
initial: 0.0,
|
|
"add-your-backend": 0.8,
|
|
"hook-the-table": 1.7,
|
|
"add-your-ui-framework": 8,
|
|
"add-more-crud-functions": 16,
|
|
"add-more-routes": 17,
|
|
"secure-your-app": 17.9,
|
|
restart: 23.8,
|
|
};
|
|
|
|
const phaseData: Record<
|
|
WalkthroughPhase,
|
|
{
|
|
code?: string;
|
|
text?: string;
|
|
end?: boolean;
|
|
}
|
|
> = {
|
|
initial: {},
|
|
"add-your-backend": {
|
|
code: `
|
|
import { Refine } from "@refinedev/core";
|
|
/* ... */
|
|
<Route path="/orders" element={<MyCustomTable />} />
|
|
`,
|
|
text: "Add your Backend",
|
|
},
|
|
"hook-the-table": {
|
|
code: `
|
|
import dataProvider from "@refinedev/nestjsx-crud";
|
|
`,
|
|
text: "Hook the Table",
|
|
},
|
|
"add-your-ui-framework": {
|
|
code: `
|
|
import { useTable } from "@refinedev/core";
|
|
/* ... */
|
|
const tableValues = useTable();
|
|
/* ... */
|
|
<table>{...}</table>
|
|
`,
|
|
text: "Add your UI Framework",
|
|
},
|
|
"add-more-crud-functions": {
|
|
code: `
|
|
import { Table } from "antd";
|
|
import { Layout, List } from "@refinedev/antd";
|
|
import { useTable } from "@refinedev/antd";
|
|
`,
|
|
text: "Add more CRUD functions",
|
|
},
|
|
"add-more-routes": {
|
|
code: `
|
|
{
|
|
___name: "orders",
|
|
___list: "/orders",
|
|
___show: "/orders/show/:id",
|
|
___create: "/orders/create",
|
|
___edit: "/orders/edit:id",
|
|
}
|
|
`,
|
|
text: "Add more Routes",
|
|
},
|
|
"secure-your-app": {
|
|
code: `
|
|
<Route path="/orders" element={<OrdersPage />} />
|
|
<Route path="/products" element={<ProductsPage />} />
|
|
<Route path="/customers" element={<CustomersPage />} />
|
|
`,
|
|
text: "Secure your App",
|
|
},
|
|
restart: {
|
|
code: `
|
|
import { AuthPage } from "@refinedev/antd";
|
|
import { Auth0Provider } from "@auth0/auth0-react";
|
|
`,
|
|
end: true,
|
|
},
|
|
};
|
|
|
|
const getNextPhase = (phase: WalkthroughPhase) => {
|
|
const currentIndex = walkthroughPhases.indexOf(phase);
|
|
if (currentIndex === -1) {
|
|
return "initial";
|
|
}
|
|
if (currentIndex === walkthroughPhases.length - 1) {
|
|
return "initial";
|
|
}
|
|
return walkthroughPhases[currentIndex + 1];
|
|
};
|
|
|
|
const isAtCheckpoint = (currentTime: number, phase: WalkthroughPhase) => {
|
|
// we can consider the checkpoint to be reached if we're at exact time or past 0.25s
|
|
const lowerBound = phaseCheckpoints[phase];
|
|
const upperBound = phaseCheckpoints[phase] + 0.125;
|
|
return currentTime >= lowerBound && currentTime <= upperBound;
|
|
};
|
|
|
|
export const LandingWalkthroughVideo = () => {
|
|
const videoRef = React.useRef<HTMLVideoElement>(null);
|
|
const [phase, setPhase] = React.useState<WalkthroughPhase>("initial");
|
|
const [isPlaying, setIsPlaying] = React.useState(false);
|
|
|
|
const inView = useInView(videoRef, {
|
|
amount: "all",
|
|
once: true,
|
|
});
|
|
|
|
const onTime = React.useCallback(
|
|
(currentTime: number) => {
|
|
const nextPhase = getNextPhase(phase);
|
|
|
|
const atNext = isAtCheckpoint(currentTime, nextPhase);
|
|
|
|
if (atNext) {
|
|
videoRef?.current?.pause();
|
|
setPhase(nextPhase);
|
|
}
|
|
},
|
|
[phase],
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (isPlaying) {
|
|
const interval = setInterval(() => {
|
|
onTime(videoRef.current?.currentTime);
|
|
}, 10);
|
|
|
|
return () => {
|
|
clearInterval(interval);
|
|
};
|
|
}
|
|
}, [isPlaying]);
|
|
|
|
const onPhaseClick = React.useCallback(() => {
|
|
videoRef.current?.play();
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (inView) {
|
|
videoRef.current?.play();
|
|
} else {
|
|
videoRef.current?.pause();
|
|
}
|
|
}, [inView]);
|
|
|
|
return (
|
|
<div
|
|
className={clsx(
|
|
"w-full",
|
|
"flex",
|
|
"items-center",
|
|
"justify-center",
|
|
"relative",
|
|
)}
|
|
>
|
|
<video
|
|
ref={videoRef}
|
|
muted={true}
|
|
playsInline={true}
|
|
controls={false}
|
|
onPlay={() => setIsPlaying(true)}
|
|
onPause={() => setIsPlaying(false)}
|
|
onEnded={() => {
|
|
if (videoRef.current) {
|
|
videoRef.current.currentTime = 0;
|
|
videoRef.current.play();
|
|
}
|
|
setPhase("initial");
|
|
}}
|
|
className={clsx(
|
|
"w-full h-full opacity-[0.99]",
|
|
"max-w-screen-walkthrough",
|
|
)}
|
|
>
|
|
<source
|
|
src="https://refine.ams3.cdn.digitaloceanspaces.com/website/static/assets/refine-walkthrough.mov"
|
|
type='video/mp4; codecs="hvc1"'
|
|
/>
|
|
<source
|
|
src="https://refine.ams3.cdn.digitaloceanspaces.com/website/static/assets/refine-walkthrough.webm"
|
|
type="video/webm;"
|
|
/>
|
|
</video>
|
|
<div className={clsx("absolute", "left-0", "bottom-11")}>
|
|
<AnimatePresence>
|
|
{phase !== "initial" && !isPlaying && (
|
|
<WalkthroughPhaseWrapper
|
|
key={phase}
|
|
code={phaseData[phase].code ?? ""}
|
|
text={phaseData[phase].text}
|
|
end={phaseData[phase].end}
|
|
onClick={onPhaseClick}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|