mirror of
https://github.com/p-stream/p-stream.git
synced 2026-01-11 20:10:32 +00:00
add migration upload
This commit is contained in:
parent
4039ae8f68
commit
b2933a5af6
5 changed files with 398 additions and 22 deletions
|
|
@ -216,6 +216,12 @@
|
|||
"title": "Download data",
|
||||
"quality": "More technical",
|
||||
"action": "Download data"
|
||||
},
|
||||
"upload": {
|
||||
"title": "Upload Data",
|
||||
"description": "Upload your previously exported data file to restore your bookmarks and progress on this account.",
|
||||
"quality": "Restore from backup",
|
||||
"action": "Upload Data"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -240,8 +246,7 @@
|
|||
"title": "Download data",
|
||||
"description": "This will download your data to your device. You can then upload it to the new server or just keep it for safekeeping.",
|
||||
"items": {
|
||||
"description": "This will download your data including:",
|
||||
"profile": "Profile information",
|
||||
"description": "Download includes:",
|
||||
"bookmarks": "Bookmarked media",
|
||||
"progress": "Watch progress"
|
||||
},
|
||||
|
|
@ -255,6 +260,32 @@
|
|||
"login": "Continue to login"
|
||||
}
|
||||
},
|
||||
"upload": {
|
||||
"title": "Upload data",
|
||||
"description": "Upload your previously exported data file to restore your bookmarks and progress on this account.",
|
||||
"status": {
|
||||
"processing": "Processing data...",
|
||||
"error": "Failed to upload your data. 😿",
|
||||
"success": "Your data has been uploaded successfully! 🎉"
|
||||
},
|
||||
"file": {
|
||||
"description": "Select the file you want to upload",
|
||||
"select": "Select file",
|
||||
"change": "Change file",
|
||||
"name": "File name"
|
||||
},
|
||||
"dataPreview": "Preview:",
|
||||
"items": {
|
||||
"bookmarks": "Bookmarked media",
|
||||
"progress": "Watch progress"
|
||||
},
|
||||
"exportedOn": "Exported on",
|
||||
"button": {
|
||||
"import": "Import data",
|
||||
"success": "Import complete",
|
||||
"home": "Continue to home"
|
||||
}
|
||||
},
|
||||
"back": "Go back"
|
||||
},
|
||||
"navigation": {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { Stepper } from "@/components/layout/Stepper";
|
||||
import { BiggerCenterContainer } from "@/components/layout/ThinContainer";
|
||||
import { VerticalLine } from "@/components/layout/VerticalLine";
|
||||
|
|
@ -65,6 +64,27 @@ export function MigrationPage() {
|
|||
<Link>{t("migration.start.options.download.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/upload")}
|
||||
className="flex-1"
|
||||
>
|
||||
<CardContent
|
||||
colorClass="!text-migration-good"
|
||||
title={t("migration.start.options.upload.title")}
|
||||
subtitle={t("migration.start.options.upload.quality")}
|
||||
description={t("migration.start.options.upload.description")}
|
||||
icon={Icons.CLOUD_ARROW_UP}
|
||||
>
|
||||
<Link>{t("migration.start.options.upload.action")}</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</BiggerCenterContainer>
|
||||
</MinimalPageLayout>
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@ import { useTranslation } from "react-i18next";
|
|||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { SettingsCard } from "@/components/layout/SettingsCard";
|
||||
import { Stepper } from "@/components/layout/Stepper";
|
||||
import { CenterContainer } from "@/components/layout/ThinContainer";
|
||||
import { Divider } from "@/components/utils/Divider";
|
||||
import { Heading2, Paragraph } from "@/components/utils/Text";
|
||||
import { Heading2, Heading3, Paragraph } from "@/components/utils/Text";
|
||||
import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout";
|
||||
import { PageTitle } from "@/pages/parts/util/PageTitle";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
|
@ -40,7 +41,7 @@ export function MigrationDownloadPage() {
|
|||
const dataUri = `data:application/json;charset=utf-8,${encodeURIComponent(dataStr)}`;
|
||||
|
||||
// Create filename with current date
|
||||
const exportFileDefaultName = `p-stream-data-${new Date().toISOString().split("T")[0]}.json`;
|
||||
const exportFileDefaultName = `mw-account-data-${new Date().toISOString().split("T")[0]}.json`;
|
||||
|
||||
// Create a temporary link element and click it to trigger download
|
||||
const linkElement = document.createElement("a");
|
||||
|
|
@ -71,24 +72,36 @@ export function MigrationDownloadPage() {
|
|||
</Paragraph>
|
||||
<SettingsCard>
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="font-bold text-white">
|
||||
{t("migration.download.title")}
|
||||
</p>
|
||||
<Heading3 className="!my-0 !text-type-secondary">
|
||||
{t("migration.download.items.description")}{" "}
|
||||
</Heading3>
|
||||
</div>
|
||||
<Divider marginClass="my-6 px-8 box-content -mx-8" />
|
||||
<div className="text-white mb-4">
|
||||
<p>{t("migration.download.items.description")}</p>
|
||||
<ul className="list-disc ml-6 mt-2">
|
||||
<li>{t("migration.download.items.profile")}</li>
|
||||
<li>
|
||||
{t("migration.download.items.bookmarks")} (
|
||||
{Object.keys(bookmarks).length} items)
|
||||
</li>
|
||||
<li>
|
||||
{t("migration.download.items.progress")} (
|
||||
{Object.keys(progress).length} items)
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="p-4 bg-background rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon={Icons.BOOKMARK} className="text-xl" />
|
||||
<span className="font-medium">
|
||||
{t("migration.download.items.bookmarks")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold mt-2">
|
||||
{Object.keys(bookmarks).length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-background rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon={Icons.CLOCK} className="text-xl" />
|
||||
<span className="font-medium">
|
||||
{t("migration.download.items.progress")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold mt-2">
|
||||
{Object.keys(progress).length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
|
|
|
|||
310
src/pages/migration/MigrationUpload.tsx
Normal file
310
src/pages/migration/MigrationUpload.tsx
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import { ChangeEvent, useCallback, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { SettingsCard } from "@/components/layout/SettingsCard";
|
||||
import { Stepper } from "@/components/layout/Stepper";
|
||||
import { CenterContainer } from "@/components/layout/ThinContainer";
|
||||
import { Divider } from "@/components/utils/Divider";
|
||||
import { Heading2, Paragraph } from "@/components/utils/Text";
|
||||
import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout";
|
||||
import { PageTitle } from "@/pages/parts/util/PageTitle";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { BookmarkMediaItem, useBookmarkStore } from "@/stores/bookmarks";
|
||||
import { ProgressMediaItem, useProgressStore } from "@/stores/progress";
|
||||
|
||||
interface UploadedData {
|
||||
account?: {
|
||||
profile?: {
|
||||
icon: string;
|
||||
colorA: string;
|
||||
colorB: string;
|
||||
};
|
||||
deviceName?: string;
|
||||
};
|
||||
bookmarks?: Record<string, BookmarkMediaItem>;
|
||||
progress?: Record<string, ProgressMediaItem>;
|
||||
exportDate?: string;
|
||||
}
|
||||
|
||||
export function MigrationUploadPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const replaceBookmarks = useBookmarkStore((s) => s.replaceBookmarks);
|
||||
const replaceProgress = useProgressStore((s) => s.replaceItems);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [status, setStatus] = useState<
|
||||
"idle" | "success" | "error" | "processing"
|
||||
>("idle");
|
||||
const [uploadedData, setUploadedData] = useState<UploadedData | null>(null);
|
||||
|
||||
const handleFileButtonClick = () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
const readFile = async (file: File) => {
|
||||
try {
|
||||
setStatus("processing");
|
||||
const fileContent = await file.text();
|
||||
const parsedData = JSON.parse(fileContent);
|
||||
|
||||
// Validate and ensure types match what we expect
|
||||
const validatedData: UploadedData = {
|
||||
...parsedData,
|
||||
bookmarks: parsedData.bookmarks
|
||||
? Object.entries(parsedData.bookmarks).reduce(
|
||||
(acc, [id, item]: [string, any]) => {
|
||||
// Ensure type is either "show" or "movie"
|
||||
if (
|
||||
typeof item.type === "string" &&
|
||||
(item.type === "show" || item.type === "movie")
|
||||
) {
|
||||
acc[id] = {
|
||||
title: item.title || "",
|
||||
year: typeof item.year === "number" ? item.year : undefined,
|
||||
poster: item.poster,
|
||||
type: item.type as "show" | "movie",
|
||||
updatedAt:
|
||||
typeof item.updatedAt === "number"
|
||||
? item.updatedAt
|
||||
: Date.now(),
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, BookmarkMediaItem>,
|
||||
)
|
||||
: undefined,
|
||||
|
||||
progress: parsedData.progress
|
||||
? Object.entries(parsedData.progress).reduce(
|
||||
(acc, [id, item]: [string, any]) => {
|
||||
// Ensure type is either "show" or "movie"
|
||||
if (
|
||||
typeof item.type === "string" &&
|
||||
(item.type === "show" || item.type === "movie")
|
||||
) {
|
||||
acc[id] = {
|
||||
title: item.title || "",
|
||||
poster: item.poster,
|
||||
type: item.type as "show" | "movie",
|
||||
updatedAt:
|
||||
typeof item.updatedAt === "number"
|
||||
? item.updatedAt
|
||||
: Date.now(),
|
||||
year: typeof item.year === "number" ? item.year : undefined,
|
||||
progress: item.progress,
|
||||
episodes: item.episodes || {},
|
||||
seasons: item.seasons || {},
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, ProgressMediaItem>,
|
||||
)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
setUploadedData(validatedData);
|
||||
setStatus("idle");
|
||||
} catch (error) {
|
||||
console.error("Error parsing JSON file:", error);
|
||||
setStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
setSelectedFile(e.target.files[0]);
|
||||
setStatus("idle");
|
||||
setUploadedData(null);
|
||||
|
||||
// Auto-read the file when selected
|
||||
const file = e.target.files[0];
|
||||
readFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = useCallback(() => {
|
||||
if (!uploadedData) return;
|
||||
|
||||
if (uploadedData.bookmarks) {
|
||||
replaceBookmarks(uploadedData.bookmarks);
|
||||
}
|
||||
|
||||
if (uploadedData.progress) {
|
||||
replaceProgress(uploadedData.progress);
|
||||
}
|
||||
|
||||
// If user profile data is available in the uploaded data, we could update that too
|
||||
|
||||
setStatus("success");
|
||||
}, [replaceBookmarks, replaceProgress, uploadedData]);
|
||||
|
||||
return (
|
||||
<MinimalPageLayout>
|
||||
<PageTitle k="migration.upload.title" subpage />
|
||||
<CenterContainer>
|
||||
{user.account ? (
|
||||
<div>
|
||||
<Stepper current={2} steps={2} className="mb-12" />
|
||||
<Heading2 className="!text-4xl !mt-0">
|
||||
{t("migration.upload.title")}
|
||||
</Heading2>
|
||||
<Paragraph className="text-lg max-w-md mb-6">
|
||||
{t("migration.upload.description")}
|
||||
</Paragraph>
|
||||
|
||||
<SettingsCard>
|
||||
<div className="flex py-6 flex-col space-y-4 items-center justify-center">
|
||||
<div className="flex flex-col space-y-2 w-full items-center">
|
||||
<p className="text-sm">
|
||||
{t("migration.upload.file.description")}:
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleFileButtonClick}
|
||||
theme="purple"
|
||||
className="w-full max-w-xs"
|
||||
padding="md:px-12 p-2.5"
|
||||
>
|
||||
<Icon icon={Icons.FILE} className="pr-2" />
|
||||
{selectedFile
|
||||
? t("migration.upload.file.change")
|
||||
: t("migration.upload.file.select")}
|
||||
</Button>
|
||||
|
||||
{selectedFile && (
|
||||
<div className="text-center mt-2 w-full">
|
||||
<span className="text-sm font-medium">
|
||||
{selectedFile.name}
|
||||
{uploadedData?.exportDate && (
|
||||
<div className="text-sm pb-2">
|
||||
{t("migration.upload.exportedOn")}:{" "}
|
||||
{new Date(
|
||||
uploadedData?.exportDate || "",
|
||||
).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "processing" && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-400">
|
||||
<Icon icon={Icons.CLOCK} className="animate-spin pr-2" />
|
||||
{t("migration.upload.status.processing")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-400">
|
||||
<Icon icon={Icons.WARNING} className="pr-2" />
|
||||
{t("migration.upload.status.error")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{uploadedData && (
|
||||
<SettingsCard className="mt-6">
|
||||
<Heading2 className="!my-0 !text-type-secondary">
|
||||
{t("migration.upload.dataPreview")}
|
||||
</Heading2>
|
||||
<Divider marginClass="my-6 px-8 box-content -mx-8" />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-background rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon={Icons.BOOKMARK} className="text-xl" />
|
||||
<span className="font-medium">
|
||||
{t("migration.upload.items.bookmarks")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold mt-2">
|
||||
{uploadedData.bookmarks
|
||||
? Object.keys(uploadedData.bookmarks).length
|
||||
: 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-background rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon={Icons.CLOCK} className="text-xl" />
|
||||
<span className="font-medium">
|
||||
{t("migration.upload.items.progress")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold mt-2">
|
||||
{uploadedData.progress
|
||||
? Object.keys(uploadedData.progress).length
|
||||
: 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex py-6 flex-col space-y-2 items-center justify-center">
|
||||
{status === "success" ? (
|
||||
<div className="flex items-center gap-2 text-green-400">
|
||||
<Icon icon={Icons.CHECKMARK} className="pr-2" />
|
||||
{t("migration.upload.status.success")}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
className="w-full max-w-xs"
|
||||
theme="purple"
|
||||
padding="md:px-12 p-2.5"
|
||||
>
|
||||
<Icon icon={Icons.CLOUD_ARROW_UP} className="pr-2" />
|
||||
{t("migration.upload.button.import")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button theme="secondary" onClick={() => navigate("/migration")}>
|
||||
{t("migration.back")}
|
||||
</Button>
|
||||
|
||||
{status === "success" && (
|
||||
<Button onClick={() => navigate("/")} theme="purple">
|
||||
{t("migration.upload.button.home")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center text-center mb-8">
|
||||
<Paragraph className="max-w-[320px] text-md">
|
||||
{t("migration.loginRequired")}
|
||||
</Paragraph>
|
||||
<Button
|
||||
theme="purple"
|
||||
className="mt-4"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
{t("migration.download.button.home")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CenterContainer>
|
||||
</MinimalPageLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ import { LoginPage } from "@/pages/Login";
|
|||
import { MigrationPage } from "@/pages/migration/Migration";
|
||||
import { MigrationDirectPage } from "@/pages/migration/MigrationDirect";
|
||||
import { MigrationDownloadPage } from "@/pages/migration/MigrationDownload";
|
||||
import { MigrationUploadPage } from "@/pages/migration/MigrationUpload";
|
||||
import { OnboardingPage } from "@/pages/onboarding/Onboarding";
|
||||
import { OnboardingExtensionPage } from "@/pages/onboarding/OnboardingExtension";
|
||||
import { OnboardingProxyPage } from "@/pages/onboarding/OnboardingProxy";
|
||||
|
|
@ -159,6 +160,7 @@ function App() {
|
|||
path="/migration/download"
|
||||
element={<MigrationDownloadPage />}
|
||||
/>
|
||||
<Route path="/migration/upload" element={<MigrationUploadPage />} />
|
||||
|
||||
<Route path="/dmca" element={<DmcaPage />} />
|
||||
{/* Support page */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue