56 changed files with 1501 additions and 157 deletions
@ -0,0 +1,5 @@ |
|||||||
|
const allowedExtensionVersion = ["0.0.1"]; |
||||||
|
|
||||||
|
export function isAllowedExtensionVersion(version: string): boolean { |
||||||
|
return allowedExtensionVersion.includes(version); |
||||||
|
} |
@ -0,0 +1,71 @@ |
|||||||
|
import { |
||||||
|
MessagesMetadata, |
||||||
|
sendToBackgroundViaRelay, |
||||||
|
} from "@plasmohq/messaging"; |
||||||
|
|
||||||
|
import { isAllowedExtensionVersion } from "@/backend/extension/compatibility"; |
||||||
|
import { ExtensionMakeRequestResponse } from "@/backend/extension/plasmo"; |
||||||
|
|
||||||
|
let activeExtension = false; |
||||||
|
|
||||||
|
function sendMessage<MessageKey extends keyof MessagesMetadata>( |
||||||
|
message: MessageKey, |
||||||
|
payload: MessagesMetadata[MessageKey]["req"] | undefined = undefined, |
||||||
|
timeout: number = -1, |
||||||
|
) { |
||||||
|
return new Promise<MessagesMetadata[MessageKey]["res"] | null>((resolve) => { |
||||||
|
if (timeout >= 0) setTimeout(() => resolve(null), timeout); |
||||||
|
sendToBackgroundViaRelay< |
||||||
|
MessagesMetadata[MessageKey]["req"], |
||||||
|
MessagesMetadata[MessageKey]["res"] |
||||||
|
>({ |
||||||
|
name: message, |
||||||
|
body: payload, |
||||||
|
}) |
||||||
|
.then((res) => { |
||||||
|
activeExtension = true; |
||||||
|
resolve(res); |
||||||
|
}) |
||||||
|
.catch(() => { |
||||||
|
activeExtension = false; |
||||||
|
resolve(null); |
||||||
|
}); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
export async function sendExtensionRequest<T>( |
||||||
|
ops: MessagesMetadata["makeRequest"]["req"], |
||||||
|
): Promise<ExtensionMakeRequestResponse<T> | null> { |
||||||
|
return sendMessage("makeRequest", ops); |
||||||
|
} |
||||||
|
|
||||||
|
export async function setDomainRule( |
||||||
|
ops: MessagesMetadata["prepareStream"]["req"], |
||||||
|
): Promise<MessagesMetadata["prepareStream"]["res"] | null> { |
||||||
|
return sendMessage("prepareStream", ops); |
||||||
|
} |
||||||
|
|
||||||
|
export async function sendPage( |
||||||
|
ops: MessagesMetadata["openPage"]["req"], |
||||||
|
): Promise<MessagesMetadata["openPage"]["res"] | null> { |
||||||
|
return sendMessage("openPage", ops); |
||||||
|
} |
||||||
|
|
||||||
|
export async function extensionInfo(): Promise< |
||||||
|
MessagesMetadata["hello"]["res"] | null |
||||||
|
> { |
||||||
|
const message = await sendMessage("hello", undefined, 300); |
||||||
|
return message; |
||||||
|
} |
||||||
|
|
||||||
|
export function isExtensionActiveCached(): boolean { |
||||||
|
return activeExtension; |
||||||
|
} |
||||||
|
|
||||||
|
export async function isExtensionActive(): Promise<boolean> { |
||||||
|
const info = await extensionInfo(); |
||||||
|
if (!info?.success) return false; |
||||||
|
const allowedVersion = isAllowedExtensionVersion(info.version); |
||||||
|
if (!allowedVersion) return false; |
||||||
|
return info.allowed && info.hasPermission; |
||||||
|
} |
@ -0,0 +1,68 @@ |
|||||||
|
export interface ExtensionBaseRequest {} |
||||||
|
|
||||||
|
export type ExtensionBaseResponse<T = object> = |
||||||
|
| ({ |
||||||
|
success: true; |
||||||
|
} & T) |
||||||
|
| { |
||||||
|
success: false; |
||||||
|
error: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export type ExtensionHelloResponse = ExtensionBaseResponse<{ |
||||||
|
version: string; |
||||||
|
allowed: boolean; |
||||||
|
hasPermission: boolean; |
||||||
|
}>; |
||||||
|
|
||||||
|
export interface ExtensionMakeRequest extends ExtensionBaseRequest { |
||||||
|
url: string; |
||||||
|
method: string; |
||||||
|
headers?: Record<string, string>; |
||||||
|
body?: string | FormData | URLSearchParams | Record<string, any>; |
||||||
|
} |
||||||
|
|
||||||
|
export type ExtensionMakeRequestResponse<T> = ExtensionBaseResponse<{ |
||||||
|
response: { |
||||||
|
statusCode: number; |
||||||
|
headers: Record<string, string>; |
||||||
|
finalUrl: string; |
||||||
|
body: T; |
||||||
|
}; |
||||||
|
}>; |
||||||
|
|
||||||
|
export interface ExtensionPrepareStreamRequest extends ExtensionBaseRequest { |
||||||
|
ruleId: number; |
||||||
|
targetDomains: string[]; |
||||||
|
requestHeaders?: Record<string, string>; |
||||||
|
responseHeaders?: Record<string, string>; |
||||||
|
} |
||||||
|
|
||||||
|
export interface MmMetadata { |
||||||
|
hello: { |
||||||
|
req: ExtensionBaseRequest; |
||||||
|
res: ExtensionHelloResponse; |
||||||
|
}; |
||||||
|
makeRequest: { |
||||||
|
req: ExtensionMakeRequest; |
||||||
|
res: ExtensionMakeRequestResponse<any>; |
||||||
|
}; |
||||||
|
prepareStream: { |
||||||
|
req: ExtensionPrepareStreamRequest; |
||||||
|
res: ExtensionBaseResponse; |
||||||
|
}; |
||||||
|
openPage: { |
||||||
|
req: ExtensionBaseRequest & { |
||||||
|
page: string; |
||||||
|
redirectUrl: string; |
||||||
|
}; |
||||||
|
res: ExtensionBaseResponse; |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
interface MpMetadata {} |
||||||
|
|
||||||
|
declare module "@plasmohq/messaging" { |
||||||
|
interface MessagesMetadata extends MmMetadata {} |
||||||
|
interface PortsMetadata extends MpMetadata {} |
||||||
|
} |
@ -0,0 +1,43 @@ |
|||||||
|
import { Stream } from "@movie-web/providers"; |
||||||
|
|
||||||
|
import { setDomainRule } from "@/backend/extension/messaging"; |
||||||
|
|
||||||
|
function extractDomain(url: string): string | null { |
||||||
|
try { |
||||||
|
const u = new URL(url); |
||||||
|
return u.hostname; |
||||||
|
} catch { |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function extractDomainsFromStream(stream: Stream): string[] { |
||||||
|
if (stream.type === "hls") { |
||||||
|
return [extractDomain(stream.playlist)].filter((v): v is string => !!v); |
||||||
|
} |
||||||
|
if (stream.type === "file") { |
||||||
|
return Object.values(stream.qualities) |
||||||
|
.map((v) => extractDomain(v.url)) |
||||||
|
.filter((v): v is string => !!v); |
||||||
|
} |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
function buildHeadersFromStream(stream: Stream): Record<string, string> { |
||||||
|
const headers: Record<string, string> = {}; |
||||||
|
Object.entries(stream.headers ?? {}).forEach((entry) => { |
||||||
|
headers[entry[0]] = entry[1]; |
||||||
|
}); |
||||||
|
Object.entries(stream.preferredHeaders ?? {}).forEach((entry) => { |
||||||
|
headers[entry[0]] = entry[1]; |
||||||
|
}); |
||||||
|
return headers; |
||||||
|
} |
||||||
|
|
||||||
|
export async function prepareStream(stream: Stream) { |
||||||
|
await setDomainRule({ |
||||||
|
ruleId: 1, |
||||||
|
targetDomains: extractDomainsFromStream(stream), |
||||||
|
requestHeaders: buildHeadersFromStream(stream), |
||||||
|
}); |
||||||
|
} |
@ -0,0 +1,27 @@ |
|||||||
|
import { |
||||||
|
makeProviders, |
||||||
|
makeStandardFetcher, |
||||||
|
targets, |
||||||
|
} from "@movie-web/providers"; |
||||||
|
|
||||||
|
import { isExtensionActiveCached } from "@/backend/extension/messaging"; |
||||||
|
import { |
||||||
|
makeExtensionFetcher, |
||||||
|
makeLoadBalancedSimpleProxyFetcher, |
||||||
|
} from "@/backend/providers/fetchers"; |
||||||
|
|
||||||
|
export function getProviders() { |
||||||
|
if (isExtensionActiveCached()) { |
||||||
|
return makeProviders({ |
||||||
|
fetcher: makeExtensionFetcher(), |
||||||
|
target: targets.BROWSER_EXTENSION, |
||||||
|
consistentIpForRequests: true, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
return makeProviders({ |
||||||
|
fetcher: makeStandardFetcher(fetch), |
||||||
|
proxiedFetcher: makeLoadBalancedSimpleProxyFetcher(), |
||||||
|
target: targets.BROWSER, |
||||||
|
}); |
||||||
|
} |
@ -0,0 +1,25 @@ |
|||||||
|
export interface StepperProps { |
||||||
|
current: number; |
||||||
|
steps: number; |
||||||
|
className?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export function Stepper(props: StepperProps) { |
||||||
|
const percentage = (props.current / props.steps) * 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-onboarding-bar rounded-full overflow-hidden"> |
||||||
|
<div |
||||||
|
className="h-full bg-onboarding-barFilled transition-[width] rounded-full" |
||||||
|
style={{ |
||||||
|
width: `${percentage.toFixed(0)}%`, |
||||||
|
}} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,18 @@ |
|||||||
|
import classNames from "classnames"; |
||||||
|
import { ReactNode } from "react"; |
||||||
|
|
||||||
|
import { Icon, Icons } from "@/components/Icon"; |
||||||
|
|
||||||
|
export function ErrorLine(props: { children?: ReactNode; className?: string }) { |
||||||
|
return ( |
||||||
|
<p |
||||||
|
className={classNames( |
||||||
|
"inline-flex items-center text-type-danger", |
||||||
|
props.className, |
||||||
|
)} |
||||||
|
> |
||||||
|
<Icon icon={Icons.WARNING} className="text-xl mr-4" /> |
||||||
|
{props.children} |
||||||
|
</p> |
||||||
|
); |
||||||
|
} |
@ -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> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,102 @@ |
|||||||
|
import classNames from "classnames"; |
||||||
|
import { Trans, useTranslation } from "react-i18next"; |
||||||
|
|
||||||
|
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 { Heading1, Heading2, Paragraph } from "@/components/utils/Text"; |
||||||
|
import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout"; |
||||||
|
import { |
||||||
|
useNavigateOnboarding, |
||||||
|
useRedirectBack, |
||||||
|
} from "@/pages/onboarding/onboardingHooks"; |
||||||
|
import { Card, CardContent, Link } from "@/pages/onboarding/utils"; |
||||||
|
import { PageTitle } from "@/pages/parts/util/PageTitle"; |
||||||
|
|
||||||
|
function VerticalLine(props: { className?: string }) { |
||||||
|
return ( |
||||||
|
<div className={classNames("w-full grid justify-center", props.className)}> |
||||||
|
<div className="w-px h-10 bg-onboarding-divider" /> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function OnboardingPage() { |
||||||
|
const navigate = useNavigateOnboarding(); |
||||||
|
const skipModal = useModal("skip"); |
||||||
|
const { completeAndRedirect } = useRedirectBack(); |
||||||
|
const { t } = useTranslation(); |
||||||
|
|
||||||
|
return ( |
||||||
|
<MinimalPageLayout> |
||||||
|
<PageTitle subpage k="global.pages.onboarding" /> |
||||||
|
<Modal id={skipModal.id}> |
||||||
|
<ModalCard> |
||||||
|
<Heading1 className="!mt-0 !mb-4 !text-2xl"> |
||||||
|
{t("onboarding.defaultConfirm.title")} |
||||||
|
</Heading1> |
||||||
|
<Paragraph className="!mt-1 !mb-12"> |
||||||
|
{t("onboarding.defaultConfirm.description")} |
||||||
|
</Paragraph> |
||||||
|
<div className="flex items-end justify-between"> |
||||||
|
<Button theme="secondary" onClick={skipModal.hide}> |
||||||
|
{t("onboarding.defaultConfirm.cancel")} |
||||||
|
</Button> |
||||||
|
<Button theme="purple" onClick={() => completeAndRedirect()}> |
||||||
|
{t("onboarding.defaultConfirm.confirm")} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</ModalCard> |
||||||
|
</Modal> |
||||||
|
<CenterContainer> |
||||||
|
<Stepper steps={2} current={1} className="mb-12" /> |
||||||
|
<Heading2 className="!mt-0 !text-3xl max-w-[435px]"> |
||||||
|
{t("onboarding.start.title")} |
||||||
|
</Heading2> |
||||||
|
<Paragraph className="max-w-[320px]"> |
||||||
|
{t("onboarding.start.explainer")} |
||||||
|
</Paragraph> |
||||||
|
|
||||||
|
<div className="w-full grid grid-cols-[1fr,auto,1fr] gap-3"> |
||||||
|
<Card onClick={() => navigate("/onboarding/proxy")}> |
||||||
|
<CardContent |
||||||
|
colorClass="!text-onboarding-good" |
||||||
|
title={t("onboarding.start.options.proxy.title")} |
||||||
|
subtitle={t("onboarding.start.options.proxy.quality")} |
||||||
|
description={t("onboarding.start.options.proxy.description")} |
||||||
|
> |
||||||
|
<Link>{t("onboarding.start.options.proxy.action")}</Link> |
||||||
|
</CardContent> |
||||||
|
</Card> |
||||||
|
<div className="grid grid-rows-[1fr,auto,1fr] justify-center gap-4"> |
||||||
|
<VerticalLine className="items-end" /> |
||||||
|
<span className="text-xs uppercase font-bold">or</span> |
||||||
|
<VerticalLine /> |
||||||
|
</div> |
||||||
|
<Card onClick={() => navigate("/onboarding/extension")}> |
||||||
|
<CardContent |
||||||
|
colorClass="!text-onboarding-best" |
||||||
|
title={t("onboarding.start.options.extension.title")} |
||||||
|
subtitle={t("onboarding.start.options.extension.quality")} |
||||||
|
description={t("onboarding.start.options.extension.description")} |
||||||
|
> |
||||||
|
<Link>{t("onboarding.start.options.extension.action")}</Link> |
||||||
|
</CardContent> |
||||||
|
</Card> |
||||||
|
</div> |
||||||
|
|
||||||
|
<p className="text-center mt-12"> |
||||||
|
<Trans i18nKey="onboarding.start.options.default.text"> |
||||||
|
<br /> |
||||||
|
<a |
||||||
|
onClick={skipModal.show} |
||||||
|
type="button" |
||||||
|
className="text-onboarding-link hover:opacity-75 cursor-pointer" |
||||||
|
/> |
||||||
|
</Trans> |
||||||
|
</p> |
||||||
|
</CenterContainer> |
||||||
|
</MinimalPageLayout> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,152 @@ |
|||||||
|
import { ReactNode } from "react"; |
||||||
|
import { Trans, useTranslation } from "react-i18next"; |
||||||
|
import { useAsyncFn, useInterval } from "react-use"; |
||||||
|
|
||||||
|
import { isAllowedExtensionVersion } from "@/backend/extension/compatibility"; |
||||||
|
import { extensionInfo, sendPage } from "@/backend/extension/messaging"; |
||||||
|
import { Button } from "@/components/buttons/Button"; |
||||||
|
import { Icon, Icons } from "@/components/Icon"; |
||||||
|
import { Loading } from "@/components/layout/Loading"; |
||||||
|
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 { |
||||||
|
useNavigateOnboarding, |
||||||
|
useRedirectBack, |
||||||
|
} from "@/pages/onboarding/onboardingHooks"; |
||||||
|
import { Card, Link } from "@/pages/onboarding/utils"; |
||||||
|
import { PageTitle } from "@/pages/parts/util/PageTitle"; |
||||||
|
|
||||||
|
type ExtensionStatus = |
||||||
|
| "unknown" |
||||||
|
| "failed" |
||||||
|
| "disallowed" |
||||||
|
| "noperms" |
||||||
|
| "outdated" |
||||||
|
| "success"; |
||||||
|
|
||||||
|
async function getExtensionState(): Promise<ExtensionStatus> { |
||||||
|
const info = await extensionInfo(); |
||||||
|
if (!info) return "unknown"; // cant talk to extension
|
||||||
|
if (!info.success) return "failed"; // extension failed to respond
|
||||||
|
if (!info.allowed) return "disallowed"; // extension is not enabled on this page
|
||||||
|
if (!info.hasPermission) return "noperms"; // extension has no perms to do it's tasks
|
||||||
|
if (!isAllowedExtensionVersion(info.version)) return "outdated"; // extension is too old
|
||||||
|
return "success"; // no problems
|
||||||
|
} |
||||||
|
|
||||||
|
export function ExtensionStatus(props: { |
||||||
|
status: ExtensionStatus; |
||||||
|
loading: boolean; |
||||||
|
}) { |
||||||
|
const { t } = useTranslation(); |
||||||
|
|
||||||
|
let content: ReactNode = null; |
||||||
|
if (props.loading || props.status === "unknown") |
||||||
|
content = ( |
||||||
|
<> |
||||||
|
<Loading /> |
||||||
|
<p>{t("onboarding.extension.status.loading")}</p> |
||||||
|
</> |
||||||
|
); |
||||||
|
if (props.status === "disallowed" || props.status === "noperms") |
||||||
|
content = ( |
||||||
|
<> |
||||||
|
<p>{t("onboarding.extension.status.disallowed")}</p> |
||||||
|
<Button |
||||||
|
onClick={() => { |
||||||
|
sendPage({ |
||||||
|
page: "PermissionGrant", |
||||||
|
redirectUrl: window.location.href, |
||||||
|
}); |
||||||
|
}} |
||||||
|
theme="purple" |
||||||
|
padding="md:px-12 p-2.5" |
||||||
|
className="mt-6" |
||||||
|
> |
||||||
|
{t("onboarding.extension.status.disallowedAction")} |
||||||
|
</Button> |
||||||
|
</> |
||||||
|
); |
||||||
|
else if (props.status === "failed") |
||||||
|
content = <p>{t("onboarding.extension.status.failed")}</p>; |
||||||
|
else if (props.status === "outdated") |
||||||
|
content = <p>{t("onboarding.extension.status.outdated")}</p>; |
||||||
|
else if (props.status === "success") |
||||||
|
content = ( |
||||||
|
<p className="flex items-center"> |
||||||
|
<Icon icon={Icons.CHECKMARK} className="text-type-success mr-4" /> |
||||||
|
{t("onboarding.extension.status.success")} |
||||||
|
</p> |
||||||
|
); |
||||||
|
return ( |
||||||
|
<> |
||||||
|
<Card> |
||||||
|
<div className="flex py-6 flex-col space-y-2 items-center justify-center"> |
||||||
|
{content} |
||||||
|
</div> |
||||||
|
</Card> |
||||||
|
<Card className="mt-4"> |
||||||
|
<div className="flex items-center space-x-7"> |
||||||
|
<Icon icon={Icons.WARNING} className="text-type-danger text-2xl" /> |
||||||
|
<p className="flex-1"> |
||||||
|
<Trans |
||||||
|
i18nKey="onboarding.extension.extensionHelp" |
||||||
|
components={{ |
||||||
|
bold: <span className="text-white" />, |
||||||
|
}} |
||||||
|
/> |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</Card> |
||||||
|
</> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function OnboardingExtensionPage() { |
||||||
|
const { t } = useTranslation(); |
||||||
|
const navigate = useNavigateOnboarding(); |
||||||
|
const { completeAndRedirect } = useRedirectBack(); |
||||||
|
|
||||||
|
const [{ loading, value }, exec] = useAsyncFn( |
||||||
|
async (triggeredManually: boolean = false) => { |
||||||
|
const status = await getExtensionState(); |
||||||
|
if (status === "success" && triggeredManually) completeAndRedirect(); |
||||||
|
return status; |
||||||
|
}, |
||||||
|
[completeAndRedirect], |
||||||
|
); |
||||||
|
useInterval(exec, 1000); |
||||||
|
|
||||||
|
// TODO proper link to install extension
|
||||||
|
return ( |
||||||
|
<MinimalPageLayout> |
||||||
|
<PageTitle subpage k="global.pages.onboarding" /> |
||||||
|
<CenterContainer> |
||||||
|
<Stepper steps={2} current={2} className="mb-12" /> |
||||||
|
<Heading2 className="!mt-0 !text-3xl max-w-[435px]"> |
||||||
|
{t("onboarding.extension.title")} |
||||||
|
</Heading2> |
||||||
|
<Paragraph className="max-w-[320px] mb-4"> |
||||||
|
{t("onboarding.extension.explainer")} |
||||||
|
</Paragraph> |
||||||
|
<Link href="https://google.com" target="_blank" className="mb-12"> |
||||||
|
{t("onboarding.extension.link")} |
||||||
|
</Link> |
||||||
|
|
||||||
|
<ExtensionStatus status={value ?? "unknown"} loading={loading} /> |
||||||
|
<div className="flex justify-between items-center mt-8"> |
||||||
|
<Button onClick={() => navigate("/onboarding")} theme="secondary"> |
||||||
|
{t("onboarding.extension.back")} |
||||||
|
</Button> |
||||||
|
{value === "success" ? ( |
||||||
|
<Button onClick={() => exec(true)} theme="purple"> |
||||||
|
{t("onboarding.extension.submit")} |
||||||
|
</Button> |
||||||
|
) : null} |
||||||
|
</div> |
||||||
|
</CenterContainer> |
||||||
|
</MinimalPageLayout> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,80 @@ |
|||||||
|
import { useState } from "react"; |
||||||
|
import { useTranslation } from "react-i18next"; |
||||||
|
import { useAsyncFn } from "react-use"; |
||||||
|
|
||||||
|
import { singularProxiedFetch } from "@/backend/helpers/fetch"; |
||||||
|
import { Button } from "@/components/buttons/Button"; |
||||||
|
import { Stepper } from "@/components/layout/Stepper"; |
||||||
|
import { CenterContainer } from "@/components/layout/ThinContainer"; |
||||||
|
import { AuthInputBox } from "@/components/text-inputs/AuthInputBox"; |
||||||
|
import { Divider } from "@/components/utils/Divider"; |
||||||
|
import { ErrorLine } from "@/components/utils/ErrorLine"; |
||||||
|
import { Heading2, Paragraph } from "@/components/utils/Text"; |
||||||
|
import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout"; |
||||||
|
import { |
||||||
|
useNavigateOnboarding, |
||||||
|
useRedirectBack, |
||||||
|
} from "@/pages/onboarding/onboardingHooks"; |
||||||
|
import { Link } from "@/pages/onboarding/utils"; |
||||||
|
import { PageTitle } from "@/pages/parts/util/PageTitle"; |
||||||
|
import { useAuthStore } from "@/stores/auth"; |
||||||
|
|
||||||
|
const testUrl = "https://postman-echo.com/get"; |
||||||
|
|
||||||
|
export function OnboardingProxyPage() { |
||||||
|
const { t } = useTranslation(); |
||||||
|
const navigate = useNavigateOnboarding(); |
||||||
|
const { completeAndRedirect } = useRedirectBack(); |
||||||
|
const [url, setUrl] = useState(""); |
||||||
|
const setProxySet = useAuthStore((s) => s.setProxySet); |
||||||
|
|
||||||
|
const [{ loading, error }, test] = useAsyncFn(async () => { |
||||||
|
if (!url.startsWith("http")) |
||||||
|
throw new Error("onboarding.proxy.input.errorInvalidUrl"); |
||||||
|
try { |
||||||
|
const res = await singularProxiedFetch(url, testUrl, {}); |
||||||
|
if (res.url !== testUrl) |
||||||
|
throw new Error("onboarding.proxy.input.errorNotProxy"); |
||||||
|
setProxySet([url]); |
||||||
|
completeAndRedirect(); |
||||||
|
} catch (e) { |
||||||
|
throw new Error("onboarding.proxy.input.errorConnection"); |
||||||
|
} |
||||||
|
}, [url, completeAndRedirect, setProxySet]); |
||||||
|
|
||||||
|
// TODO proper link to proxy deployment docs
|
||||||
|
return ( |
||||||
|
<MinimalPageLayout> |
||||||
|
<PageTitle subpage k="global.pages.onboarding" /> |
||||||
|
<CenterContainer> |
||||||
|
<Stepper steps={2} current={2} className="mb-12" /> |
||||||
|
<Heading2 className="!mt-0 !text-3xl max-w-[435px]"> |
||||||
|
{t("onboarding.proxy.title")} |
||||||
|
</Heading2> |
||||||
|
<Paragraph className="max-w-[320px] !mb-5"> |
||||||
|
{t("onboarding.proxy.explainer")} |
||||||
|
</Paragraph> |
||||||
|
<Link>{t("onboarding.proxy.link")}</Link> |
||||||
|
<div className="w-[400px] max-w-full mt-14 mb-28"> |
||||||
|
<AuthInputBox |
||||||
|
label={t("onboarding.proxy.input.label")} |
||||||
|
value={url} |
||||||
|
onChange={setUrl} |
||||||
|
placeholder={t("onboarding.proxy.input.placeholder")} |
||||||
|
className="mb-4" |
||||||
|
/> |
||||||
|
{error ? <ErrorLine>{t(error.message)}</ErrorLine> : null} |
||||||
|
</div> |
||||||
|
<Divider /> |
||||||
|
<div className="flex justify-between"> |
||||||
|
<Button theme="secondary" onClick={() => navigate("/onboarding")}> |
||||||
|
{t("onboarding.proxy.back")} |
||||||
|
</Button> |
||||||
|
<Button theme="purple" loading={loading} onClick={test}> |
||||||
|
{t("onboarding.proxy.submit")} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</CenterContainer> |
||||||
|
</MinimalPageLayout> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,37 @@ |
|||||||
|
import { useCallback } from "react"; |
||||||
|
import { useLocation, 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 setCompleted = useOnboardingStore((s) => s.setCompleted); |
||||||
|
|
||||||
|
const redirectBack = useCallback(() => { |
||||||
|
navigate(url ?? "/"); |
||||||
|
}, [navigate, url]); |
||||||
|
|
||||||
|
const completeAndRedirect = useCallback(() => { |
||||||
|
setCompleted(true); |
||||||
|
redirectBack(); |
||||||
|
}, [redirectBack, setCompleted]); |
||||||
|
|
||||||
|
return { completeAndRedirect }; |
||||||
|
} |
||||||
|
|
||||||
|
export function useNavigateOnboarding() { |
||||||
|
const navigate = useNavigate(); |
||||||
|
const loc = useLocation(); |
||||||
|
const nav = useCallback( |
||||||
|
(path: string) => { |
||||||
|
navigate({ |
||||||
|
pathname: path, |
||||||
|
search: loc.search, |
||||||
|
}); |
||||||
|
}, |
||||||
|
[navigate, loc], |
||||||
|
); |
||||||
|
return nav; |
||||||
|
} |
@ -0,0 +1,91 @@ |
|||||||
|
import classNames from "classnames"; |
||||||
|
import { ReactNode } from "react"; |
||||||
|
import { useNavigate } from "react-router-dom"; |
||||||
|
|
||||||
|
import { Icon, Icons } from "@/components/Icon"; |
||||||
|
import { Heading2, Heading3, Paragraph } from "@/components/utils/Text"; |
||||||
|
|
||||||
|
export function Card(props: { |
||||||
|
children?: React.ReactNode; |
||||||
|
className?: string; |
||||||
|
onClick?: () => void; |
||||||
|
}) { |
||||||
|
return ( |
||||||
|
<div |
||||||
|
className={classNames( |
||||||
|
{ |
||||||
|
"bg-onboarding-card duration-300 border border-onboarding-border rounded-lg p-7": |
||||||
|
true, |
||||||
|
"hover:bg-onboarding-cardHover transition-colors cursor-pointer": |
||||||
|
!!props.onClick, |
||||||
|
}, |
||||||
|
props.className, |
||||||
|
)} |
||||||
|
onClick={props.onClick} |
||||||
|
> |
||||||
|
{props.children} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function CardContent(props: { |
||||||
|
title: ReactNode; |
||||||
|
description: ReactNode; |
||||||
|
subtitle: ReactNode; |
||||||
|
colorClass: string; |
||||||
|
children?: React.ReactNode; |
||||||
|
}) { |
||||||
|
return ( |
||||||
|
<div className="grid grid-rows-[1fr,auto] h-full"> |
||||||
|
<div> |
||||||
|
<Icon |
||||||
|
icon={Icons.RISING_STAR} |
||||||
|
className={classNames("text-4xl mb-8 block", props.colorClass)} |
||||||
|
/> |
||||||
|
<Heading3 |
||||||
|
className={classNames( |
||||||
|
"!mt-0 !mb-0 !text-xs uppercase", |
||||||
|
props.colorClass, |
||||||
|
)} |
||||||
|
> |
||||||
|
{props.subtitle} |
||||||
|
</Heading3> |
||||||
|
<Heading2 className="!mb-0 !mt-1 !text-base">{props.title}</Heading2> |
||||||
|
<Paragraph className="max-w-[320px] !my-4"> |
||||||
|
{props.description} |
||||||
|
</Paragraph> |
||||||
|
</div> |
||||||
|
<div>{props.children}</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function Link(props: { |
||||||
|
children?: React.ReactNode; |
||||||
|
to?: string; |
||||||
|
href?: string; |
||||||
|
className?: string; |
||||||
|
target?: "_blank"; |
||||||
|
}) { |
||||||
|
const navigate = useNavigate(); |
||||||
|
return ( |
||||||
|
<a |
||||||
|
onClick={() => { |
||||||
|
if (props.to) navigate(props.to); |
||||||
|
}} |
||||||
|
href={props.href} |
||||||
|
target={props.target} |
||||||
|
className={classNames( |
||||||
|
"text-onboarding-link cursor-pointer inline-flex gap-2 items-center group hover:opacity-75 transition-opacity", |
||||||
|
props.className, |
||||||
|
)} |
||||||
|
rel="noreferrer" |
||||||
|
> |
||||||
|
{props.children} |
||||||
|
<Icon |
||||||
|
icon={Icons.ARROW_RIGHT} |
||||||
|
className="group-hover:translate-x-0.5 transition-transform text-xl group-active:translate-x-0" |
||||||
|
/> |
||||||
|
</a> |
||||||
|
); |
||||||
|
} |
@ -1,44 +0,0 @@ |
|||||||
import { useTranslation } from "react-i18next"; |
|
||||||
|
|
||||||
import { FlagIcon } from "@/components/FlagIcon"; |
|
||||||
import { Dropdown } from "@/components/form/Dropdown"; |
|
||||||
import { Heading1 } from "@/components/utils/Text"; |
|
||||||
import { appLanguageOptions } from "@/setup/i18n"; |
|
||||||
import { getLocaleInfo, sortLangCodes } from "@/utils/language"; |
|
||||||
|
|
||||||
export function LocalePart(props: { |
|
||||||
language: string; |
|
||||||
setLanguage: (l: string) => void; |
|
||||||
}) { |
|
||||||
const { t } = useTranslation(); |
|
||||||
const sorted = sortLangCodes(appLanguageOptions.map((item) => item.code)); |
|
||||||
|
|
||||||
const options = appLanguageOptions |
|
||||||
.sort((a, b) => sorted.indexOf(a.code) - sorted.indexOf(b.code)) |
|
||||||
.map((opt) => ({ |
|
||||||
id: opt.code, |
|
||||||
name: `${opt.name}${opt.nativeName ? ` — ${opt.nativeName}` : ""}`, |
|
||||||
leftIcon: <FlagIcon langCode={opt.code} />, |
|
||||||
})); |
|
||||||
|
|
||||||
const selected = options.find( |
|
||||||
(item) => item.id === getLocaleInfo(props.language)?.code, |
|
||||||
); |
|
||||||
|
|
||||||
return ( |
|
||||||
<div> |
|
||||||
<Heading1 border>{t("settings.locale.title")}</Heading1> |
|
||||||
<p className="text-white font-bold mb-3"> |
|
||||||
{t("settings.locale.language")} |
|
||||||
</p> |
|
||||||
<p className="max-w-[20rem] font-medium"> |
|
||||||
{t("settings.locale.languageDescription")} |
|
||||||
</p> |
|
||||||
<Dropdown |
|
||||||
options={options} |
|
||||||
selectedItem={selected || options[0]} |
|
||||||
setSelectedItem={(opt) => props.setLanguage(opt.id)} |
|
||||||
/> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
@ -0,0 +1,67 @@ |
|||||||
|
import { useTranslation } from "react-i18next"; |
||||||
|
|
||||||
|
import { Toggle } from "@/components/buttons/Toggle"; |
||||||
|
import { FlagIcon } from "@/components/FlagIcon"; |
||||||
|
import { Dropdown } from "@/components/form/Dropdown"; |
||||||
|
import { Heading1 } from "@/components/utils/Text"; |
||||||
|
import { appLanguageOptions } from "@/setup/i18n"; |
||||||
|
import { getLocaleInfo, sortLangCodes } from "@/utils/language"; |
||||||
|
|
||||||
|
export function PreferencesPart(props: { |
||||||
|
language: string; |
||||||
|
setLanguage: (l: string) => void; |
||||||
|
enableThumbnails: boolean; |
||||||
|
setEnableThumbnails: (v: boolean) => void; |
||||||
|
}) { |
||||||
|
const { t } = useTranslation(); |
||||||
|
const sorted = sortLangCodes(appLanguageOptions.map((item) => item.code)); |
||||||
|
|
||||||
|
const options = appLanguageOptions |
||||||
|
.sort((a, b) => sorted.indexOf(a.code) - sorted.indexOf(b.code)) |
||||||
|
.map((opt) => ({ |
||||||
|
id: opt.code, |
||||||
|
name: `${opt.name}${opt.nativeName ? ` — ${opt.nativeName}` : ""}`, |
||||||
|
leftIcon: <FlagIcon langCode={opt.code} />, |
||||||
|
})); |
||||||
|
|
||||||
|
const selected = options.find( |
||||||
|
(item) => item.id === getLocaleInfo(props.language)?.code, |
||||||
|
); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="space-y-12"> |
||||||
|
<Heading1 border>{t("settings.preferences.title")}</Heading1> |
||||||
|
<div> |
||||||
|
<p className="text-white font-bold mb-3"> |
||||||
|
{t("settings.preferences.language")} |
||||||
|
</p> |
||||||
|
<p className="max-w-[20rem] font-medium"> |
||||||
|
{t("settings.preferences.languageDescription")} |
||||||
|
</p> |
||||||
|
<Dropdown |
||||||
|
options={options} |
||||||
|
selectedItem={selected || options[0]} |
||||||
|
setSelectedItem={(opt) => props.setLanguage(opt.id)} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<p className="text-white font-bold mb-3"> |
||||||
|
{t("settings.preferences.thumbnail")} |
||||||
|
</p> |
||||||
|
<p className="max-w-[25rem] font-medium"> |
||||||
|
{t("settings.preferences.thumbnailDescription")} |
||||||
|
</p> |
||||||
|
<div |
||||||
|
onClick={() => props.setEnableThumbnails(!props.enableThumbnails)} |
||||||
|
className="bg-dropdown-background hover:bg-dropdown-hoverBackground select-none my-4 cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg" |
||||||
|
> |
||||||
|
<Toggle enabled={props.enableThumbnails} /> |
||||||
|
<p className="flex-1 text-white font-bold"> |
||||||
|
{t("settings.preferences.thumbnailLabel")} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,195 @@ |
|||||||
|
import classNames from "classnames"; |
||||||
|
import { ReactNode } from "react"; |
||||||
|
import { useTranslation } from "react-i18next"; |
||||||
|
import { useNavigate } from "react-router-dom"; |
||||||
|
import { useAsync } from "react-use"; |
||||||
|
|
||||||
|
import { isExtensionActive } from "@/backend/extension/messaging"; |
||||||
|
import { singularProxiedFetch } from "@/backend/helpers/fetch"; |
||||||
|
import { Button } from "@/components/buttons/Button"; |
||||||
|
import { Icon, Icons } from "@/components/Icon"; |
||||||
|
import { SettingsCard } from "@/components/layout/SettingsCard"; |
||||||
|
import { |
||||||
|
StatusCircle, |
||||||
|
StatusCircleProps, |
||||||
|
} from "@/components/player/internals/StatusCircle"; |
||||||
|
import { Heading3 } from "@/components/utils/Text"; |
||||||
|
import { useAuthStore } from "@/stores/auth"; |
||||||
|
|
||||||
|
const testUrl = "https://postman-echo.com/get"; |
||||||
|
|
||||||
|
type Status = "success" | "unset" | "error"; |
||||||
|
|
||||||
|
type SetupData = { |
||||||
|
extension: Status; |
||||||
|
proxy: Status; |
||||||
|
defaultProxy: Status; |
||||||
|
}; |
||||||
|
|
||||||
|
function testProxy(url: string) { |
||||||
|
return new Promise<void>((resolve, reject) => { |
||||||
|
setTimeout(() => reject(new Error("Timed out!")), 1000); |
||||||
|
singularProxiedFetch(url, testUrl, {}) |
||||||
|
.then((res) => { |
||||||
|
if (res.url !== testUrl) return reject(new Error("Not a proxy")); |
||||||
|
resolve(); |
||||||
|
}) |
||||||
|
.catch(reject); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function useIsSetup() { |
||||||
|
const proxyUrls = useAuthStore((s) => s.proxySet); |
||||||
|
const { loading, value } = useAsync(async (): Promise<SetupData> => { |
||||||
|
const extensionStatus: Status = (await isExtensionActive()) |
||||||
|
? "success" |
||||||
|
: "unset"; |
||||||
|
let proxyStatus: Status = "unset"; |
||||||
|
if (proxyUrls && proxyUrls.length > 0) { |
||||||
|
try { |
||||||
|
await testProxy(proxyUrls[0]); |
||||||
|
proxyStatus = "success"; |
||||||
|
} catch { |
||||||
|
proxyStatus = "error"; |
||||||
|
} |
||||||
|
} |
||||||
|
return { |
||||||
|
extension: extensionStatus, |
||||||
|
proxy: proxyStatus, |
||||||
|
defaultProxy: "success", |
||||||
|
}; |
||||||
|
}, [proxyUrls]); |
||||||
|
|
||||||
|
let globalState: Status = "unset"; |
||||||
|
if (value?.extension === "success" || value?.proxy === "success") |
||||||
|
globalState = "success"; |
||||||
|
if (value?.proxy === "error" || value?.extension === "error") |
||||||
|
globalState = "error"; |
||||||
|
|
||||||
|
return { |
||||||
|
setupStates: value, |
||||||
|
globalState, |
||||||
|
loading, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function SetupCheckList(props: { |
||||||
|
status: Status; |
||||||
|
grey?: boolean; |
||||||
|
highlight?: boolean; |
||||||
|
children?: ReactNode; |
||||||
|
}) { |
||||||
|
const { t } = useTranslation(); |
||||||
|
const statusMap: Record<Status, StatusCircleProps["type"]> = { |
||||||
|
error: "error", |
||||||
|
success: "success", |
||||||
|
unset: "noresult", |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="flex items-start text-type-dimmed my-4"> |
||||||
|
<StatusCircle |
||||||
|
type={statusMap[props.status]} |
||||||
|
className={classNames({ |
||||||
|
"!text-video-scraping-noresult !bg-video-scraping-noresult opacity-50": |
||||||
|
props.grey, |
||||||
|
"scale-90 mr-3": true, |
||||||
|
})} |
||||||
|
/> |
||||||
|
<div> |
||||||
|
<p |
||||||
|
className={classNames({ |
||||||
|
"!text-white": props.grey && props.highlight, |
||||||
|
"!text-type-dimmed opacity-75": props.grey && !props.highlight, |
||||||
|
"text-type-danger": props.status === "error", |
||||||
|
"text-white": props.status === "success", |
||||||
|
})} |
||||||
|
> |
||||||
|
{props.children} |
||||||
|
</p> |
||||||
|
{props.status === "error" ? ( |
||||||
|
<p className="max-w-96"> |
||||||
|
{t("settings.connections.setup.itemError")} |
||||||
|
</p> |
||||||
|
) : null} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function SetupPart() { |
||||||
|
const { t } = useTranslation(); |
||||||
|
const navigate = useNavigate(); |
||||||
|
const { loading, setupStates, globalState } = useIsSetup(); |
||||||
|
if (loading || !setupStates) return <p>Loading states...</p>; // TODO proper loading screen
|
||||||
|
|
||||||
|
const textLookupMap: Record< |
||||||
|
Status, |
||||||
|
{ title: string; desc: string; button: string } |
||||||
|
> = { |
||||||
|
error: { |
||||||
|
title: "settings.connections.setup.errorStatus.title", |
||||||
|
desc: "settings.connections.setup.errorStatus.description", |
||||||
|
button: "settings.connections.setup.redoSetup", |
||||||
|
}, |
||||||
|
success: { |
||||||
|
title: "settings.connections.setup.successStatus.title", |
||||||
|
desc: "settings.connections.setup.successStatus.description", |
||||||
|
button: "settings.connections.setup.redoSetup", |
||||||
|
}, |
||||||
|
unset: { |
||||||
|
title: "settings.connections.setup.unsetStatus.title", |
||||||
|
desc: "settings.connections.setup.unsetStatus.description", |
||||||
|
button: "settings.connections.setup.doSetup", |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<SettingsCard> |
||||||
|
<div className="flex items-start gap-4"> |
||||||
|
<div> |
||||||
|
<div |
||||||
|
className={classNames({ |
||||||
|
"rounded-full h-12 w-12 flex bg-opacity-15 justify-center items-center": |
||||||
|
true, |
||||||
|
"text-type-success bg-type-success": globalState === "success", |
||||||
|
"text-type-danger bg-type-danger": |
||||||
|
globalState === "error" || globalState === "unset", |
||||||
|
})} |
||||||
|
> |
||||||
|
<Icon |
||||||
|
icon={globalState === "success" ? Icons.CHECKMARK : Icons.X} |
||||||
|
className="text-xl" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div className="flex-1"> |
||||||
|
<Heading3 className="!mb-3"> |
||||||
|
{t(textLookupMap[globalState].title)} |
||||||
|
</Heading3> |
||||||
|
<p className="max-w-[20rem] font-medium mb-6"> |
||||||
|
{t(textLookupMap[globalState].desc)} |
||||||
|
</p> |
||||||
|
<SetupCheckList status={setupStates.extension}> |
||||||
|
{t("settings.connections.setup.items.extension")} |
||||||
|
</SetupCheckList> |
||||||
|
<SetupCheckList status={setupStates.proxy}> |
||||||
|
{t("settings.connections.setup.items.proxy")} |
||||||
|
</SetupCheckList> |
||||||
|
<SetupCheckList |
||||||
|
grey |
||||||
|
highlight={globalState === "unset"} |
||||||
|
status={setupStates.defaultProxy} |
||||||
|
> |
||||||
|
{t("settings.connections.setup.items.default")} |
||||||
|
</SetupCheckList> |
||||||
|
</div> |
||||||
|
<div className="mt-5"> |
||||||
|
<Button theme="purple" onClick={() => navigate("/onboarding")}> |
||||||
|
{t(textLookupMap[globalState].button)} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</SettingsCard> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,22 @@ |
|||||||
|
import { create } from "zustand"; |
||||||
|
import { persist } from "zustand/middleware"; |
||||||
|
import { immer } from "zustand/middleware/immer"; |
||||||
|
|
||||||
|
export interface OnboardingStore { |
||||||
|
completed: boolean; |
||||||
|
setCompleted(v: boolean): void; |
||||||
|
} |
||||||
|
|
||||||
|
export const useOnboardingStore = create( |
||||||
|
persist( |
||||||
|
immer<OnboardingStore>((set) => ({ |
||||||
|
completed: false, |
||||||
|
setCompleted(v) { |
||||||
|
set((s) => { |
||||||
|
s.completed = v; |
||||||
|
}); |
||||||
|
}, |
||||||
|
})), |
||||||
|
{ name: "__MW::onboarding" }, |
||||||
|
), |
||||||
|
); |
@ -0,0 +1,24 @@ |
|||||||
|
import { create } from "zustand"; |
||||||
|
import { persist } from "zustand/middleware"; |
||||||
|
import { immer } from "zustand/middleware/immer"; |
||||||
|
|
||||||
|
export interface PreferencesStore { |
||||||
|
enableThumbnails: boolean; |
||||||
|
setEnableThumbnails(v: boolean): void; |
||||||
|
} |
||||||
|
|
||||||
|
export const usePreferencesStore = create( |
||||||
|
persist( |
||||||
|
immer<PreferencesStore>((set) => ({ |
||||||
|
enableThumbnails: false, |
||||||
|
setEnableThumbnails(v) { |
||||||
|
set((s) => { |
||||||
|
s.enableThumbnails = v; |
||||||
|
}); |
||||||
|
}, |
||||||
|
})), |
||||||
|
{ |
||||||
|
name: "__MW::preferences", |
||||||
|
}, |
||||||
|
), |
||||||
|
); |
@ -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 completed, no onboarding needed
|
||||||
|
const completed = useOnboardingStore.getState().completed; |
||||||
|
if (completed) return false; |
||||||
|
|
||||||
|
return true; |
||||||
|
} |
Loading…
Reference in new issue