Basic onboarding structure

This commit is contained in:
mrjvs 2024-01-16 20:28:33 +01:00
parent fa2b610ea6
commit 925f3dff19
13 changed files with 281 additions and 3 deletions

View File

@ -0,0 +1,25 @@
export interface StepperProps {
current: number;
steps: number;
className?: string;
}
export function Stepper(props: StepperProps) {
const percentage = (props.current / (props.steps + 1)) * 100;
return (
<div className={props.className}>
<p className="mb-2">
{props.current}/{props.steps}
</p>
<div className="max-w-full h-1 w-32 bg-white rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-[width] rounded-full"
style={{
width: `${percentage.toFixed(0)}%`,
}}
/>
</div>
</div>
);
}

View File

@ -1,3 +1,4 @@
import classNames from "classnames";
import { ReactNode } from "react"; import { ReactNode } from "react";
interface ThinContainerProps { interface ThinContainerProps {
@ -16,3 +17,16 @@ export function ThinContainer(props: ThinContainerProps) {
</div> </div>
); );
} }
export function CenterContainer(props: ThinContainerProps) {
return (
<div
className={classNames(
"min-h-screen w-full flex justify-center p-8 items-center",
props.classNames,
)}
>
<div className="w-[600px] max-w-full">{props.children}</div>
</div>
);
}

View File

@ -1,6 +1,12 @@
import { RunOutput } from "@movie-web/providers"; import { RunOutput } from "@movie-web/providers";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import {
Navigate,
useLocation,
useNavigate,
useParams,
} from "react-router-dom";
import { useAsync } from "react-use";
import { usePlayer } from "@/components/player/hooks/usePlayer"; import { usePlayer } from "@/components/player/hooks/usePlayer";
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta"; import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
@ -15,9 +21,10 @@ import { ScrapeErrorPart } from "@/pages/parts/player/ScrapeErrorPart";
import { ScrapingPart } from "@/pages/parts/player/ScrapingPart"; import { ScrapingPart } from "@/pages/parts/player/ScrapingPart";
import { useLastNonPlayerLink } from "@/stores/history"; import { useLastNonPlayerLink } from "@/stores/history";
import { PlayerMeta, playerStatus } from "@/stores/player/slices/source"; import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
import { needsOnboarding } from "@/utils/onboarding";
import { parseTimestamp } from "@/utils/timestamp"; import { parseTimestamp } from "@/utils/timestamp";
export function PlayerView() { export function RealPlayerView() {
const navigate = useNavigate(); const navigate = useNavigate();
const params = useParams<{ const params = useParams<{
media: string; media: string;
@ -109,4 +116,25 @@ export function PlayerView() {
); );
} }
export function PlayerView() {
const loc = useLocation();
const { loading, error, value } = useAsync(() => {
return needsOnboarding();
});
if (error) throw new Error("Failed to detect onboarding");
if (loading) return null;
if (value)
return (
<Navigate
replace
to={{
pathname: "/onboarding",
search: `redirect=${encodeURIComponent(loc.pathname)}`,
}}
/>
);
return <RealPlayerView />;
}
export default PlayerView; export default PlayerView;

View File

@ -0,0 +1,28 @@
import { Link } from "react-router-dom";
import { BrandPill } from "@/components/layout/BrandPill";
import { BlurEllipsis } from "@/pages/layouts/SubPageLayout";
export function MinimalPageLayout(props: { children: React.ReactNode }) {
return (
<div
className="bg-background-main min-h-screen"
style={{
backgroundImage:
"linear-gradient(to bottom, var(--tw-gradient-from), var(--tw-gradient-to) 800px)",
}}
>
<BlurEllipsis />
{/* Main page */}
<div className="fixed px-7 py-5 left-0 top-0">
<Link
className="block tabbable rounded-full text-xs ssm:text-base"
to="/"
>
<BrandPill clickable />
</Link>
</div>
<div className="min-h-screen">{props.children}</div>
</div>
);
}

View File

@ -0,0 +1,48 @@
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/buttons/Button";
import { Stepper } from "@/components/layout/Stepper";
import { CenterContainer } from "@/components/layout/ThinContainer";
import { Modal, ModalCard, useModal } from "@/components/overlays/Modal";
import { Heading2, Paragraph } from "@/components/utils/Text";
import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout";
import { useRedirectBack } from "@/pages/onboarding/onboardingHooks";
import { PageTitle } from "@/pages/parts/util/PageTitle";
export function OnboardingPage() {
const navigate = useNavigate();
const skipModal = useModal("skip");
const { skipAndRedirect } = useRedirectBack();
return (
<MinimalPageLayout>
<PageTitle subpage k="global.pages.about" />
<Modal id={skipModal.id}>
<ModalCard>
<ModalCard>
<Heading2 className="!mt-0">Lorem ipsum</Heading2>
<Paragraph>Lorem ipsum Lorem ipsum Lorem ipsum</Paragraph>
<Button theme="secondary" onClick={skipModal.hide}>
Lorem ipsum
</Button>
<Button theme="danger" onClick={() => skipAndRedirect()}>
Lorem ipsum
</Button>
</ModalCard>
</ModalCard>
</Modal>
<CenterContainer>
<Stepper steps={2} current={1} className="mb-12" />
<Heading2 className="!mt-0">Lorem ipsum</Heading2>
<Paragraph>Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum</Paragraph>
<Button onClick={() => navigate("/onboarding/proxy")}>
Custom proxy
</Button>
<Button onClick={() => navigate("/onboarding/extension")}>
Extension
</Button>
<Button onClick={skipModal.show}>Default</Button>
</CenterContainer>
</MinimalPageLayout>
);
}

View File

@ -0,0 +1,27 @@
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/buttons/Button";
import { Stepper } from "@/components/layout/Stepper";
import { CenterContainer } from "@/components/layout/ThinContainer";
import { Heading2, Paragraph } from "@/components/utils/Text";
import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout";
import { PageTitle } from "@/pages/parts/util/PageTitle";
export function OnboardingExtensionPage() {
const navigate = useNavigate();
return (
<MinimalPageLayout>
<PageTitle subpage k="global.pages.about" />
<CenterContainer>
<Stepper steps={2} current={2} className="mb-12" />
<Heading2 className="!mt-0">Lorem ipsum</Heading2>
<Paragraph>Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum</Paragraph>
<Button onClick={() => navigate("/onboarding")}>Back</Button>
<Button onClick={() => alert("Check extension here or something")}>
Check extension
</Button>
</CenterContainer>
</MinimalPageLayout>
);
}

View File

@ -0,0 +1,27 @@
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/buttons/Button";
import { Stepper } from "@/components/layout/Stepper";
import { CenterContainer } from "@/components/layout/ThinContainer";
import { Heading2, Paragraph } from "@/components/utils/Text";
import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout";
import { PageTitle } from "@/pages/parts/util/PageTitle";
export function OnboardingProxyPage() {
const navigate = useNavigate();
return (
<MinimalPageLayout>
<PageTitle subpage k="global.pages.about" />
<CenterContainer>
<Stepper steps={2} current={2} className="mb-12" />
<Heading2 className="!mt-0">Lorem ipsum</Heading2>
<Paragraph>Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum</Paragraph>
<Button onClick={() => navigate("/onboarding")}>Back</Button>
<Button onClick={() => alert("Check proxy or smth")}>
Check extension
</Button>
</CenterContainer>
</MinimalPageLayout>
);
}

View File

@ -0,0 +1,22 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useQueryParam } from "@/hooks/useQueryParams";
import { useOnboardingStore } from "@/stores/onboarding";
export function useRedirectBack() {
const [url] = useQueryParam("redirect");
const navigate = useNavigate();
const setSkipped = useOnboardingStore((s) => s.setSkipped);
const redirectBack = useCallback(() => {
navigate(url ?? "/");
}, [navigate, url]);
const skipAndRedirect = useCallback(() => {
setSkipped(true);
redirectBack();
}, [redirectBack, setSkipped]);
return { redirectBack, skipAndRedirect };
}

View File

@ -19,6 +19,9 @@ import { DmcaPage, shouldHaveDmcaPage } from "@/pages/Dmca";
import { NotFoundPage } from "@/pages/errors/NotFoundPage"; import { NotFoundPage } from "@/pages/errors/NotFoundPage";
import { HomePage } from "@/pages/HomePage"; import { HomePage } from "@/pages/HomePage";
import { LoginPage } from "@/pages/Login"; import { LoginPage } from "@/pages/Login";
import { OnboardingPage } from "@/pages/onboarding/Onboarding";
import { OnboardingExtensionPage } from "@/pages/onboarding/OnboardingExtension";
import { OnboardingProxyPage } from "@/pages/onboarding/OnboardingProxy";
import { RegisterPage } from "@/pages/Register"; import { RegisterPage } from "@/pages/Register";
import { Layout } from "@/setup/Layout"; import { Layout } from "@/setup/Layout";
import { useHistoryListener } from "@/stores/history"; import { useHistoryListener } from "@/stores/history";
@ -119,6 +122,12 @@ function App() {
<Route path="/register" element={<RegisterPage />} /> <Route path="/register" element={<RegisterPage />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/about" element={<AboutPage />} /> <Route path="/about" element={<AboutPage />} />
<Route path="/onboarding" element={<OnboardingPage />} />
<Route
path="/onboarding/extension"
element={<OnboardingExtensionPage />}
/>
<Route path="/onboarding/proxy" element={<OnboardingProxyPage />} />
{shouldHaveDmcaPage() ? ( {shouldHaveDmcaPage() ? (
<Route path="/dmca" element={<DmcaPage />} /> <Route path="/dmca" element={<DmcaPage />} />

View File

@ -19,6 +19,7 @@ interface Config {
DISALLOWED_IDS: string; DISALLOWED_IDS: string;
TURNSTILE_KEY: string; TURNSTILE_KEY: string;
CDN_REPLACEMENTS: string; CDN_REPLACEMENTS: string;
HAS_ONBOARDING: string;
} }
export interface RuntimeConfig { export interface RuntimeConfig {
@ -34,6 +35,7 @@ export interface RuntimeConfig {
DISALLOWED_IDS: string[]; DISALLOWED_IDS: string[];
TURNSTILE_KEY: string | null; TURNSTILE_KEY: string | null;
CDN_REPLACEMENTS: Array<string[]>; CDN_REPLACEMENTS: Array<string[]>;
HAS_ONBOARDING: boolean;
} }
const env: Record<keyof Config, undefined | string> = { const env: Record<keyof Config, undefined | string> = {
@ -49,6 +51,7 @@ const env: Record<keyof Config, undefined | string> = {
DISALLOWED_IDS: import.meta.env.VITE_DISALLOWED_IDS, DISALLOWED_IDS: import.meta.env.VITE_DISALLOWED_IDS,
TURNSTILE_KEY: import.meta.env.VITE_TURNSTILE_KEY, TURNSTILE_KEY: import.meta.env.VITE_TURNSTILE_KEY,
CDN_REPLACEMENTS: import.meta.env.VITE_CDN_REPLACEMENTS, CDN_REPLACEMENTS: import.meta.env.VITE_CDN_REPLACEMENTS,
HAS_ONBOARDING: import.meta.env.VITE_HAS_ONBOARDING,
}; };
// loads from different locations, in order: environment (VITE_{KEY}), window (public/config.js) // loads from different locations, in order: environment (VITE_{KEY}), window (public/config.js)
@ -82,6 +85,7 @@ export function conf(): RuntimeConfig {
.split(",") .split(",")
.map((v) => v.trim()), .map((v) => v.trim()),
NORMAL_ROUTER: getKey("NORMAL_ROUTER", "false") === "true", NORMAL_ROUTER: getKey("NORMAL_ROUTER", "false") === "true",
HAS_ONBOARDING: getKey("HAS_ONBOARDING", "false") === "true",
TURNSTILE_KEY: turnstileKey.length > 0 ? turnstileKey : null, TURNSTILE_KEY: turnstileKey.length > 0 ? turnstileKey : null,
DISALLOWED_IDS: getKey("DISALLOWED_IDS", "") DISALLOWED_IDS: getKey("DISALLOWED_IDS", "")
.split(",") .split(",")

View File

@ -46,7 +46,8 @@ export function useLastNonPlayerLink() {
(v) => (v) =>
!v.path.startsWith("/media") && // cannot be a player link !v.path.startsWith("/media") && // cannot be a player link
location.pathname !== v.path && // cannot be current link location.pathname !== v.path && // cannot be current link
!v.path.startsWith("/s/"), // cannot be a quick search link !v.path.startsWith("/s/") && // cannot be a quick search link
!v.path.startsWith("/onboarding"), // cannot be an onboarding link
); );
return route?.path ?? "/"; return route?.path ?? "/";
}, [routes, location]); }, [routes, location]);

View File

@ -0,0 +1,22 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
export interface OnboardingStore {
skipped: boolean;
setSkipped(v: boolean): void;
}
export const useOnboardingStore = create(
persist(
immer<OnboardingStore>((set) => ({
skipped: false,
setSkipped(v) {
set((s) => {
s.skipped = v;
});
},
})),
{ name: "__MW::onboarding" },
),
);

23
src/utils/onboarding.ts Normal file
View File

@ -0,0 +1,23 @@
import { isExtensionActive } from "@/backend/extension/messaging";
import { conf } from "@/setup/config";
import { useAuthStore } from "@/stores/auth";
import { useOnboardingStore } from "@/stores/onboarding";
export async function needsOnboarding(): Promise<boolean> {
// if onboarding is dislabed, no onboarding needed
if (!conf().HAS_ONBOARDING) return false;
// if extension is active and working, no onboarding needed
const extensionActive = await isExtensionActive();
if (extensionActive) return false;
// if there is any custom proxy urls, no onboarding needed
const proxyUrls = useAuthStore.getState().proxySet;
if (proxyUrls) return false;
// if onboarding has been skipped, no onboarding needed
const skipped = useOnboardingStore.getState().skipped;
if (skipped) return false;
return true;
}