mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-28 16:41:48 +01:00
Basic onboarding structure
This commit is contained in:
parent
fa2b610ea6
commit
925f3dff19
25
src/components/layout/Stepper.tsx
Normal file
25
src/components/layout/Stepper.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -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;
|
||||||
|
28
src/pages/layouts/MinimalPageLayout.tsx
Normal file
28
src/pages/layouts/MinimalPageLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
48
src/pages/onboarding/Onboarding.tsx
Normal file
48
src/pages/onboarding/Onboarding.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
27
src/pages/onboarding/OnboardingExtension.tsx
Normal file
27
src/pages/onboarding/OnboardingExtension.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
27
src/pages/onboarding/OnboardingProxy.tsx
Normal file
27
src/pages/onboarding/OnboardingProxy.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
22
src/pages/onboarding/onboardingHooks.ts
Normal file
22
src/pages/onboarding/onboardingHooks.ts
Normal 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 };
|
||||||
|
}
|
@ -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 />} />
|
||||||
|
@ -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(",")
|
||||||
|
@ -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]);
|
||||||
|
22
src/stores/onboarding/index.tsx
Normal file
22
src/stores/onboarding/index.tsx
Normal 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
23
src/utils/onboarding.ts
Normal 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;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user