56 changed files with 1501 additions and 157 deletions
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
const allowedExtensionVersion = ["0.0.1"]; |
||||
|
||||
export function isAllowedExtensionVersion(version: string): boolean { |
||||
return allowedExtensionVersion.includes(version); |
||||
} |
@ -0,0 +1,71 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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