mirror of
https://github.com/p-stream/p-stream.git
synced 2026-04-19 22:32:05 +00:00
Merge branch 'pr-migration' into production-2
This commit is contained in:
commit
b82e34935f
11 changed files with 514 additions and 13 deletions
|
|
@ -112,6 +112,7 @@
|
|||
"pagetitle": "{{title}} - P-Stream",
|
||||
"register": "Register",
|
||||
"settings": "Settings",
|
||||
"migration": "Migration",
|
||||
"jip": "Jip"
|
||||
}
|
||||
},
|
||||
|
|
@ -197,6 +198,45 @@
|
|||
"show": "Show"
|
||||
}
|
||||
},
|
||||
"migration": {
|
||||
"start": {
|
||||
"title": "Migrate your data",
|
||||
"explainer": "If you wish to migrate or backup your data, you can do so using the options below. This will allow you to keep your data when you switch backend servers.",
|
||||
"options": {
|
||||
"or": "or",
|
||||
"direct": {
|
||||
"description": "This will directly migrate your data to the new server. This is the fastest option. <br /><br />This option allows you to keep your passphrase the same!",
|
||||
"title": "Direct migration",
|
||||
"quality": "Easiest and fastest",
|
||||
"action": "Transfer data"
|
||||
},
|
||||
"download": {
|
||||
"description": "This will download your data to your device. You can then upload it to the new server or just keep it for safekeeping.",
|
||||
"title": "Download data",
|
||||
"quality": "More technical",
|
||||
"action": "Download data"
|
||||
}
|
||||
}
|
||||
},
|
||||
"direct": {
|
||||
"title": "Direct migration",
|
||||
"description": "Enter the destination backend URL to migrate your current account data to a new backend. This keeps your passphrase the same!",
|
||||
"backendLabel": "Destination Backend URL",
|
||||
"recaptchaLabel": "ReCaptcha Key (Optional)",
|
||||
"toggleLable": "Needs ReCaptcha?",
|
||||
"loginRequired": "You must be logged in to migrate your data! Please go back and login to continue.",
|
||||
"status": {
|
||||
"error": "Failed to migrate your data. 😿",
|
||||
"success": "Your data has been migrated successfully! 🎉"
|
||||
},
|
||||
"button": {
|
||||
"migrate": "Migrate",
|
||||
"processing": "Processing...",
|
||||
"home": "Go home",
|
||||
"login": "Continue to login"
|
||||
}
|
||||
}
|
||||
},
|
||||
"navigation": {
|
||||
"banner": {
|
||||
"offline": "Check your internet connection, silly goose! 🦢"
|
||||
|
|
@ -267,6 +307,7 @@
|
|||
"start": {
|
||||
"explainer": "To get the best streams possible, you will need to choose which streaming method you want to use.",
|
||||
"options": {
|
||||
"or": "or",
|
||||
"default": {
|
||||
"text": "I don't want good quality streams, use the default setup."
|
||||
},
|
||||
|
|
@ -577,7 +618,11 @@
|
|||
"server": {
|
||||
"description": "If you would like to connect to a custom backend to store your data, enable this and provide the URL. <0>Instructions.</0>",
|
||||
"label": "Custom server",
|
||||
"urlLabel": "Custom server URL"
|
||||
"urlLabel": "Custom server URL",
|
||||
"migration": {
|
||||
"description": "<0>Migrate my data</0> to a new server. ",
|
||||
"link": "Migrate my data"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"doSetup": "Do setup",
|
||||
|
|
|
|||
|
|
@ -21,9 +21,7 @@ export function verifyValidMnemonic(mnemonic: string) {
|
|||
return validateMnemonic(mnemonic, wordlist);
|
||||
}
|
||||
|
||||
export async function keysFromMnemonic(mnemonic: string): Promise<Keys> {
|
||||
const seed = await seedFromMnemonic(mnemonic);
|
||||
|
||||
export async function keysFromSeed(seed: Uint8Array): Promise<Keys> {
|
||||
const { privateKey, publicKey } = forge.pki.ed25519.generateKeyPair({
|
||||
seed,
|
||||
});
|
||||
|
|
@ -35,6 +33,12 @@ export async function keysFromMnemonic(mnemonic: string): Promise<Keys> {
|
|||
};
|
||||
}
|
||||
|
||||
export async function keysFromMnemonic(mnemonic: string): Promise<Keys> {
|
||||
const seed = await seedFromMnemonic(mnemonic);
|
||||
|
||||
return keysFromSeed(seed);
|
||||
}
|
||||
|
||||
export function genMnemonic(): string {
|
||||
return generateMnemonic(wordlist);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ export enum Icons {
|
|||
WEB = "web",
|
||||
SHRINK = "shrink",
|
||||
STRETCH = "stretch",
|
||||
CLOUD_ARROW_UP = "cloud_arrow_up",
|
||||
FILE_ARROW_DOWN = "file_arrow_down",
|
||||
}
|
||||
|
||||
export interface IconProps {
|
||||
|
|
@ -157,6 +159,8 @@ const iconList: Record<Icons, string> = {
|
|||
</svg>`,
|
||||
shrink: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512" fill="currentColor"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M439 7c9.4-9.4 24.6-9.4 33.9 0l32 32c9.4 9.4 9.4 24.6 0 33.9l-87 87 39 39c6.9 6.9 8.9 17.2 5.2 26.2s-12.5 14.8-22.2 14.8l-144 0c-13.3 0-24-10.7-24-24l0-144c0-9.7 5.8-18.5 14.8-22.2s19.3-1.7 26.2 5.2l39 39L439 7zM72 272l144 0c13.3 0 24 10.7 24 24l0 144c0 9.7-5.8 18.5-14.8 22.2s-19.3 1.7-26.2-5.2l-39-39L73 505c-9.4 9.4-24.6 9.4-33.9 0L7 473c-9.4-9.4-9.4-24.6 0-33.9l87-87L55 313c-6.9-6.9-8.9-17.2-5.2-26.2s12.5-14.8 22.2-14.8z"/></svg>`,
|
||||
stretch: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512" fill="currentColor"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M344 0L488 0c13.3 0 24 10.7 24 24l0 144c0 9.7-5.8 18.5-14.8 22.2s-19.3 1.7-26.2-5.2l-39-39-87 87c-9.4 9.4-24.6 9.4-33.9 0l-32-32c-9.4-9.4-9.4-24.6 0-33.9l87-87L327 41c-6.9-6.9-8.9-17.2-5.2-26.2S334.3 0 344 0zM168 512L24 512c-13.3 0-24-10.7-24-24L0 344c0-9.7 5.8-18.5 14.8-22.2s19.3-1.7 26.2 5.2l39 39 87-87c9.4-9.4 24.6-9.4 33.9 0l32 32c9.4 9.4 9.4 24.6 0 33.9l-87 87 39 39c6.9 6.9 8.9 17.2 5.2 26.2s-12.5 14.8-22.2 14.8z"/></svg>`,
|
||||
cloud_arrow_up: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" viewBox="0 0 640 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M144 480C64.5 480 0 415.5 0 336c0-62.8 40.2-116.2 96.2-135.9c-.1-2.7-.2-5.4-.2-8.1c0-88.4 71.6-160 160-160c59.3 0 111 32.2 138.7 80.2C409.9 102 428.3 96 448 96c53 0 96 43 96 96c0 12.2-2.3 23.8-6.4 34.6C596 238.4 640 290.1 640 352c0 70.7-57.3 128-128 128H144zm79-217c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l39-39V392c0 13.3 10.7 24 24 24s24-10.7 24-24V257.9l39 39c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-80-80c-9.4-9.4-24.6-9.4-33.9 0l-80 80z" fill="currentColor"/></svg>`,
|
||||
file_arrow_down: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" viewBox="0 0 384 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M64 0C28.7 0 0 28.7 0 64V448c0 35.3 28.7 64 64 64H320c35.3 0 64-28.7 64-64V160H256c-17.7 0-32-14.3-32-32V0H64zM256 0V128H384L256 0zM216 232V334.1l31-31c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-72 72c-9.4 9.4-24.6 9.4-33.9 0l-72-72c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l31 31V232c0-13.3 10.7-24 24-24s24 10.7 24 24z" fill="currentColor"/></svg>`,
|
||||
};
|
||||
|
||||
function ChromeCastButton() {
|
||||
|
|
|
|||
9
src/components/layout/VerticalLine.tsx
Normal file
9
src/components/layout/VerticalLine.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import classNames from "classnames";
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
125
src/hooks/auth/useMigration.ts
Normal file
125
src/hooks/auth/useMigration.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { useCallback } from "react";
|
||||
|
||||
import { SessionResponse } from "@/backend/accounts/auth";
|
||||
import { bookmarkMediaToInput } from "@/backend/accounts/bookmarks";
|
||||
import {
|
||||
base64ToBuffer,
|
||||
bytesToBase64,
|
||||
bytesToBase64Url,
|
||||
encryptData,
|
||||
keysFromMnemonic,
|
||||
keysFromSeed,
|
||||
signChallenge,
|
||||
} from "@/backend/accounts/crypto";
|
||||
import { importBookmarks, importProgress } from "@/backend/accounts/import";
|
||||
import { getLoginChallengeToken, loginAccount } from "@/backend/accounts/login";
|
||||
import { progressMediaItemToInputs } from "@/backend/accounts/progress";
|
||||
import {
|
||||
getRegisterChallengeToken,
|
||||
registerAccount,
|
||||
} from "@/backend/accounts/register";
|
||||
import { removeSession } from "@/backend/accounts/sessions";
|
||||
import { getSettings } from "@/backend/accounts/settings";
|
||||
import {
|
||||
UserResponse,
|
||||
getBookmarks,
|
||||
getProgress,
|
||||
getUser,
|
||||
} from "@/backend/accounts/user";
|
||||
import { useAuthData } from "@/hooks/auth/useAuthData";
|
||||
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||
import { AccountWithToken, useAuthStore } from "@/stores/auth";
|
||||
import { BookmarkMediaItem, useBookmarkStore } from "@/stores/bookmarks";
|
||||
import { ProgressMediaItem, useProgressStore } from "@/stores/progress";
|
||||
|
||||
export interface RegistrationData {
|
||||
recaptchaToken?: string;
|
||||
mnemonic: string;
|
||||
userData: {
|
||||
device: string;
|
||||
profile: {
|
||||
colorA: string;
|
||||
colorB: string;
|
||||
icon: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface LoginData {
|
||||
mnemonic: string;
|
||||
userData: {
|
||||
device: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function useMigration() {
|
||||
const currentAccount = useAuthStore((s) => s.account);
|
||||
const progress = useProgressStore((s) => s.items);
|
||||
const bookmarks = useBookmarkStore((s) => s.bookmarks);
|
||||
const { login: userDataLogin } = useAuthData();
|
||||
|
||||
const importData = async (
|
||||
backendUrl: string,
|
||||
account: AccountWithToken,
|
||||
progressItems: Record<string, ProgressMediaItem>,
|
||||
bookmarkItems: Record<string, BookmarkMediaItem>,
|
||||
) => {
|
||||
if (
|
||||
Object.keys(progressItems).length === 0 &&
|
||||
Object.keys(bookmarkItems).length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const progressInputs = Object.entries(progressItems).flatMap(
|
||||
([tmdbId, item]) => progressMediaItemToInputs(tmdbId, item),
|
||||
);
|
||||
|
||||
const bookmarkInputs = Object.entries(bookmarkItems).map(([tmdbId, item]) =>
|
||||
bookmarkMediaToInput(tmdbId, item),
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
importProgress(backendUrl, account, progressInputs),
|
||||
importBookmarks(backendUrl, account, bookmarkInputs),
|
||||
]);
|
||||
};
|
||||
|
||||
const migrate = useCallback(
|
||||
async (backendUrl: string, recaptchaToken?: string) => {
|
||||
if (!currentAccount) return;
|
||||
|
||||
const { challenge } = await getRegisterChallengeToken(
|
||||
backendUrl,
|
||||
recaptchaToken || undefined, // Pass undefined if token is not provided
|
||||
);
|
||||
const keys = await keysFromSeed(base64ToBuffer(currentAccount.seed));
|
||||
const signature = await signChallenge(keys, challenge);
|
||||
const registerResult = await registerAccount(backendUrl, {
|
||||
challenge: {
|
||||
code: challenge,
|
||||
signature,
|
||||
},
|
||||
publicKey: bytesToBase64Url(keys.publicKey),
|
||||
device: await encryptData(currentAccount.deviceName, keys.seed),
|
||||
profile: currentAccount.profile,
|
||||
});
|
||||
|
||||
const account = await userDataLogin(
|
||||
registerResult,
|
||||
registerResult.user,
|
||||
registerResult.session,
|
||||
bytesToBase64(keys.seed),
|
||||
);
|
||||
|
||||
await importData(backendUrl, account, progress, bookmarks);
|
||||
|
||||
return account;
|
||||
},
|
||||
[currentAccount, userDataLogin, bookmarks, progress],
|
||||
);
|
||||
|
||||
return {
|
||||
migrate,
|
||||
};
|
||||
}
|
||||
65
src/pages/migration/Migration.tsx
Normal file
65
src/pages/migration/Migration.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { Stepper } from "@/components/layout/Stepper";
|
||||
import { CenterContainer } from "@/components/layout/ThinContainer";
|
||||
import { VerticalLine } from "@/components/layout/VerticalLine";
|
||||
import { Heading2, Paragraph } from "@/components/utils/Text";
|
||||
import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout";
|
||||
import { Card, CardContent, Link } from "@/pages/migration/utils";
|
||||
import { PageTitle } from "@/pages/parts/util/PageTitle";
|
||||
|
||||
export function MigrationPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<MinimalPageLayout>
|
||||
<PageTitle subpage k="global.pages.migration" />
|
||||
<CenterContainer>
|
||||
<Stepper steps={2} current={1} className="mb-12" />
|
||||
<Heading2 className="!mt-0 !text-3xl max-w-[435px]">
|
||||
{t("migration.start.title")}
|
||||
</Heading2>
|
||||
<Paragraph className="max-w-[320px]">
|
||||
{t("migration.start.explainer")}
|
||||
</Paragraph>
|
||||
|
||||
<div className="w-full flex flex-col md:flex-row gap-3">
|
||||
<Card onClick={() => navigate("/migration/direct")}>
|
||||
<CardContent
|
||||
colorClass="!text-onboarding-best"
|
||||
title={t("migration.start.options.direct.title")}
|
||||
subtitle={t("migration.start.options.direct.quality")}
|
||||
description={
|
||||
<Trans i18nKey="migration.start.options.direct.description" />
|
||||
}
|
||||
icon={Icons.CLOUD_ARROW_UP}
|
||||
>
|
||||
<Link>{t("migration.start.options.direct.action")}</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="hidden md:grid grid-rows-[1fr,auto,1fr] justify-center gap-4">
|
||||
<VerticalLine className="items-end" />
|
||||
<span className="text-xs uppercase font-bold">
|
||||
{t("migration.start.options.or")}
|
||||
</span>
|
||||
<VerticalLine />
|
||||
</div>
|
||||
<Card onClick={() => navigate("/migration/download")}>
|
||||
<CardContent
|
||||
colorClass="!text-migration-good"
|
||||
title={t("migration.start.options.download.title")}
|
||||
subtitle={t("migration.start.options.download.quality")}
|
||||
description={t("migration.start.options.download.description")}
|
||||
icon={Icons.FILE_ARROW_DOWN}
|
||||
>
|
||||
<Link>{t("migration.start.options.download.action")}</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CenterContainer>
|
||||
</MinimalPageLayout>
|
||||
);
|
||||
}
|
||||
139
src/pages/migration/MigrationDirect.tsx
Normal file
139
src/pages/migration/MigrationDirect.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { SettingsCard } from "@/components/layout/SettingsCard";
|
||||
import { CenterContainer } from "@/components/layout/ThinContainer";
|
||||
import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
|
||||
import { Divider } from "@/components/utils/Divider";
|
||||
import { Heading2, Paragraph } from "@/components/utils/Text";
|
||||
import { useAuth } from "@/hooks/auth/useAuth";
|
||||
import { useMigration } from "@/hooks/auth/useMigration";
|
||||
import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout";
|
||||
import { PageTitle } from "@/pages/parts/util/PageTitle";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
export function MigrationDirectPage() {
|
||||
const { t } = useTranslation();
|
||||
const user = useAuthStore();
|
||||
const { logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { migrate } = useMigration();
|
||||
const [backendUrl, setBackendUrl] = useState("");
|
||||
const [status, setStatus] = useState<
|
||||
"idle" | "success" | "error" | "processing"
|
||||
>("idle");
|
||||
const updateBackendUrl = useAuthStore((state) => state.setBackendUrl);
|
||||
|
||||
const handleMigration = useCallback(async () => {
|
||||
if (!backendUrl) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert("Please provide a Backend URL.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus("processing");
|
||||
const account = await migrate(backendUrl);
|
||||
if (account) {
|
||||
setStatus("success");
|
||||
await logout();
|
||||
updateBackendUrl(backendUrl);
|
||||
} else {
|
||||
setStatus("error");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during migration:", error);
|
||||
setStatus("error");
|
||||
}
|
||||
}, [backendUrl, migrate, updateBackendUrl, logout]);
|
||||
|
||||
const continueButton = () => {
|
||||
if (status === "success") {
|
||||
navigate("/login");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MinimalPageLayout>
|
||||
<PageTitle subpage k="global.pages.migration" />
|
||||
<CenterContainer>
|
||||
{user.account ? (
|
||||
<div>
|
||||
<Heading2 className="!text-4xl">
|
||||
{" "}
|
||||
{t("migration.direct.title")}
|
||||
</Heading2>
|
||||
<div className="space-y-6 max-w-3xl mx-auto">
|
||||
<Paragraph className="text-lg max-w-md">
|
||||
{t("migration.direct.description")}
|
||||
</Paragraph>
|
||||
<SettingsCard>
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="font-bold text-white">
|
||||
{t("migration.direct.backendLabel")}
|
||||
</p>
|
||||
</div>
|
||||
{backendUrl !== null && (
|
||||
<>
|
||||
<Divider marginClass="my-6 px-8 box-content -mx-8" />
|
||||
<AuthInputBox
|
||||
placeholder="https://"
|
||||
value={backendUrl ?? ""}
|
||||
onChange={setBackendUrl}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
<div className="text-center">
|
||||
{status !== "success" && (
|
||||
<Button theme="purple" onClick={handleMigration}>
|
||||
{status === "processing"
|
||||
? t("migration.direct.button.processing")
|
||||
: t("migration.direct.button.migrate")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status === "success" && (
|
||||
<div>
|
||||
<Button
|
||||
theme="purple"
|
||||
className="mt-4"
|
||||
onClick={continueButton}
|
||||
>
|
||||
{t("migration.direct.button.login")}
|
||||
</Button>
|
||||
<p className="text-green-600 mt-4">
|
||||
{t("migration.direct.status.success")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<p className="text-red-600 mt-4">
|
||||
{t("migration.direct.status.error")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center text-center mb-8">
|
||||
<Paragraph className="max-w-[320px] text-md">
|
||||
{t("migration.direct.loginRequired")}
|
||||
</Paragraph>
|
||||
<Button
|
||||
theme="purple"
|
||||
className="mt-4"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
{t("migration.direct.button.home")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CenterContainer>
|
||||
</MinimalPageLayout>
|
||||
);
|
||||
}
|
||||
92
src/pages/migration/utils.tsx
Normal file
92
src/pages/migration/utils.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
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;
|
||||
icon: Icons;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-rows-[1fr,auto] h-full">
|
||||
<div>
|
||||
<Icon
|
||||
icon={props.icon}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ 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 { VerticalLine } from "@/components/layout/VerticalLine";
|
||||
import { Modal, ModalCard, useModal } from "@/components/overlays/Modal";
|
||||
import { Heading1, Heading2, Paragraph } from "@/components/utils/Text";
|
||||
import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout";
|
||||
|
|
@ -15,14 +16,6 @@ import { Card, CardContent, Link } from "@/pages/onboarding/utils";
|
|||
import { PageTitle } from "@/pages/parts/util/PageTitle";
|
||||
import { getProxyUrls } from "@/utils/proxyUrls";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function HorizontalLine(props: { className?: string }) {
|
||||
return (
|
||||
<div className={classNames("w-full grid justify-center", props.className)}>
|
||||
|
|
@ -81,7 +74,9 @@ export function OnboardingPage() {
|
|||
</Card>
|
||||
<div className="hidden md:grid grid-rows-[1fr,auto,1fr] justify-center gap-4">
|
||||
<VerticalLine className="items-end" />
|
||||
<span className="text-xs uppercase font-bold">or</span>
|
||||
<span className="text-xs uppercase font-bold">
|
||||
{t("onboarding.start.options.or")}
|
||||
</span>
|
||||
<VerticalLine />
|
||||
</div>
|
||||
<Card onClick={() => navigate("/onboarding/proxy")}>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
|
|||
import { Divider } from "@/components/utils/Divider";
|
||||
import { Heading1 } from "@/components/utils/Text";
|
||||
import { SetupPart } from "@/pages/parts/settings/SetupPart";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
interface ProxyEditProps {
|
||||
proxyUrls: string[] | null;
|
||||
|
|
@ -116,6 +117,7 @@ function ProxyEdit({ proxyUrls, setProxyUrls }: ProxyEditProps) {
|
|||
|
||||
function BackendEdit({ backendUrl, setBackendUrl }: BackendEditProps) {
|
||||
const { t } = useTranslation();
|
||||
const user = useAuthStore();
|
||||
return (
|
||||
<SettingsCard>
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
|
|
@ -130,6 +132,18 @@ function BackendEdit({ backendUrl, setBackendUrl }: BackendEditProps) {
|
|||
</MwLink>
|
||||
</Trans>
|
||||
</p>
|
||||
{user.account && (
|
||||
<div>
|
||||
<br />
|
||||
<p className="max-w-[20rem] font-medium">
|
||||
<Trans i18nKey="settings.connections.server.migration.description">
|
||||
<MwLink to="/migration">
|
||||
{t("settings.connections.server.migration.link")}
|
||||
</MwLink>
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Toggle
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import { NotFoundPage } from "@/pages/errors/NotFoundPage";
|
|||
import { HomePage } from "@/pages/HomePage";
|
||||
import { JipPage } from "@/pages/Jip";
|
||||
import { LoginPage } from "@/pages/Login";
|
||||
import { MigrationPage } from "@/pages/migration/Migration";
|
||||
import { MigrationDirectPage } from "@/pages/migration/MigrationDirect";
|
||||
import { OnboardingPage } from "@/pages/onboarding/Onboarding";
|
||||
import { OnboardingExtensionPage } from "@/pages/onboarding/OnboardingExtension";
|
||||
import { OnboardingProxyPage } from "@/pages/onboarding/OnboardingProxy";
|
||||
|
|
@ -148,6 +150,13 @@ function App() {
|
|||
element={<OnboardingExtensionPage />}
|
||||
/>
|
||||
<Route path="/onboarding/proxy" element={<OnboardingProxyPage />} />
|
||||
|
||||
<Route path="/migration" element={<MigrationDirectPage />} />
|
||||
{/* Migration pages - awaiting import and export fixes
|
||||
<Route path="/migration" element={<MigrationPage />} />
|
||||
<Route path="/migration/direct" element={<MigrationDirectPage />} />
|
||||
*/}
|
||||
|
||||
{shouldHaveDmcaPage() ? (
|
||||
<Route path="/dmca" element={<DmcaPage />} />
|
||||
) : null}
|
||||
|
|
|
|||
Loading…
Reference in a new issue