diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json
index cb88b2a8..5459aaa6 100644
--- a/src/assets/locales/en.json
+++ b/src/assets/locales/en.json
@@ -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": {
diff --git a/src/pages/migration/Migration.tsx b/src/pages/migration/Migration.tsx
index 95dab9e6..bd66622d 100644
--- a/src/pages/migration/Migration.tsx
+++ b/src/pages/migration/Migration.tsx
@@ -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() {
{t("migration.start.options.download.action")}
+
+
+
+ {t("migration.start.options.or")}
+
+
+
+ navigate("/migration/upload")}
+ className="flex-1"
+ >
+
+ {t("migration.start.options.upload.action")}
+
+
diff --git a/src/pages/migration/MigrationDownload.tsx b/src/pages/migration/MigrationDownload.tsx
index d137a0a1..859dbb31 100644
--- a/src/pages/migration/MigrationDownload.tsx
+++ b/src/pages/migration/MigrationDownload.tsx
@@ -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() {
-
- {t("migration.download.title")}
-
+
+ {t("migration.download.items.description")}{" "}
+
-
-
{t("migration.download.items.description")}
-
- - {t("migration.download.items.profile")}
- -
- {t("migration.download.items.bookmarks")} (
- {Object.keys(bookmarks).length} items)
-
- -
- {t("migration.download.items.progress")} (
- {Object.keys(progress).length} items)
-
-
+
+
+
+
+
+
+ {t("migration.download.items.bookmarks")}
+
+
+
+ {Object.keys(bookmarks).length}
+
+
+
+
+
+
+
+ {t("migration.download.items.progress")}
+
+
+
+ {Object.keys(progress).length}
+
+
diff --git a/src/pages/migration/MigrationUpload.tsx b/src/pages/migration/MigrationUpload.tsx
new file mode 100644
index 00000000..eaf5d475
--- /dev/null
+++ b/src/pages/migration/MigrationUpload.tsx
@@ -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;
+ progress?: Record;
+ exportDate?: string;
+}
+
+export function MigrationUploadPage() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const user = useAuthStore();
+ const fileInputRef = useRef(null);
+ const replaceBookmarks = useBookmarkStore((s) => s.replaceBookmarks);
+ const replaceProgress = useProgressStore((s) => s.replaceItems);
+ const [selectedFile, setSelectedFile] = useState(null);
+ const [status, setStatus] = useState<
+ "idle" | "success" | "error" | "processing"
+ >("idle");
+ const [uploadedData, setUploadedData] = useState(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,
+ )
+ : 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,
+ )
+ : undefined,
+ };
+
+ setUploadedData(validatedData);
+ setStatus("idle");
+ } catch (error) {
+ console.error("Error parsing JSON file:", error);
+ setStatus("error");
+ }
+ };
+
+ const handleFileChange = (e: ChangeEvent) => {
+ 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 (
+
+
+
+ {user.account ? (
+
+
+
+ {t("migration.upload.title")}
+
+
+ {t("migration.upload.description")}
+
+
+
+
+
+
+ {t("migration.upload.file.description")}:
+
+
+
+
+
+
+ {selectedFile && (
+
+
+ {selectedFile.name}
+ {uploadedData?.exportDate && (
+
+ {t("migration.upload.exportedOn")}:{" "}
+ {new Date(
+ uploadedData?.exportDate || "",
+ ).toLocaleDateString()}
+
+ )}
+
+
+ )}
+
+ {status === "processing" && (
+
+
+ {t("migration.upload.status.processing")}
+
+ )}
+
+ {status === "error" && (
+
+
+ {t("migration.upload.status.error")}
+
+ )}
+
+
+
+ {uploadedData && (
+
+
+ {t("migration.upload.dataPreview")}
+
+
+
+
+
+
+
+
+ {t("migration.upload.items.bookmarks")}
+
+
+
+ {uploadedData.bookmarks
+ ? Object.keys(uploadedData.bookmarks).length
+ : 0}
+
+
+
+
+
+
+
+ {t("migration.upload.items.progress")}
+
+
+
+ {uploadedData.progress
+ ? Object.keys(uploadedData.progress).length
+ : 0}
+
+
+
+
+
+ {status === "success" ? (
+
+
+ {t("migration.upload.status.success")}
+
+ ) : (
+
+ )}
+
+
+ )}
+
+
+
+
+ {status === "success" && (
+
+ )}
+
+
+ ) : (
+
+
+ {t("migration.loginRequired")}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/setup/App.tsx b/src/setup/App.tsx
index fca7a3ee..bbfa9bfd 100644
--- a/src/setup/App.tsx
+++ b/src/setup/App.tsx
@@ -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={}
/>
+ } />
} />
{/* Support page */}