mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 23:55:43 -04:00
Helpers that took Lingui's `t` as a parameter rendered empty strings because the macro only transforms `t` imported from the macro. Use msg descriptors plus i18n._ instead, so mapped error toasts and native server-URL errors are real, extractable strings. Also map Better Auth error codes to localized messages instead of showing raw server text, and send a REGISTRATION_CLOSED code from the auth hook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
984 lines
32 KiB
TypeScript
984 lines
32 KiB
TypeScript
import type { MessageDescriptor } from "@lingui/core";
|
|
import { msg } from "@lingui/core/macro";
|
|
import { Trans, useLingui } from "@lingui/react/macro";
|
|
import { IconCloudUpload, IconLink } from "@tabler/icons-react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardTitle } from "@/components/ui/card";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import {
|
|
Dialog,
|
|
DialogClose,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Progress } from "@/components/ui/progress";
|
|
import { Spinner } from "@/components/ui/spinner";
|
|
import { getErrorMessage } from "@/lib/error-messages";
|
|
import { client, orpc } from "@/lib/orpc/client";
|
|
import { invalidateTrackingQueries } from "@/lib/orpc/invalidate";
|
|
import type { NormalizedImport } from "@sofa/api/schemas";
|
|
import { formatList } from "@sofa/i18n/format";
|
|
|
|
// ─── Source Configs ──────────────────────────────────────────
|
|
|
|
type ImportSource = "trakt" | "simkl" | "letterboxd";
|
|
|
|
interface SourceConfig {
|
|
source: ImportSource;
|
|
label: string;
|
|
description: MessageDescriptor;
|
|
accept: string;
|
|
icon: React.ReactNode;
|
|
supportsOAuth: boolean;
|
|
}
|
|
|
|
/** Turns an `accept` string like ".json,.zip" into a readable label like "JSON or ZIP". */
|
|
function formatAcceptLabel(accept: string): string {
|
|
const formats = accept
|
|
.split(",")
|
|
.map((ext) => ext.trim().replace(/^\./, "").toUpperCase())
|
|
.filter(Boolean);
|
|
return formatList(formats, { type: "disjunction" });
|
|
}
|
|
|
|
// ─── Provider Logos ──────────────────────────────────────────
|
|
|
|
function TraktLogo({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="1em"
|
|
height="1em"
|
|
viewBox="0 0 24 24"
|
|
className={className}
|
|
aria-hidden
|
|
>
|
|
<title>Trakt</title>
|
|
<path
|
|
fill="currentColor"
|
|
d="m15.082 15.107l-.73-.73l9.578-9.583a5 5 0 0 0-.115-.575L13.662 14.382l1.08 1.08l-.73.73l-1.81-1.81l11.22-11.238c-.075-.15-.155-.3-.25-.44L11.508 14.377l2.154 2.155l-.73.73l-7.193-7.199l.73-.73l4.309 4.31L22.546 1.86A5.62 5.62 0 0 0 18.362 0H5.635A5.637 5.637 0 0 0 0 5.634V18.37A5.63 5.63 0 0 0 5.635 24h12.732C21.477 24 24 21.48 24 18.37V6.19l-8.913 8.918zm-4.314-2.155L6.814 8.988l.73-.73l3.954 3.96zm1.075-1.084l-3.954-3.96l.73-.73l3.959 3.96zm9.853 5.688a4.14 4.14 0 0 1-4.14 4.14H6.438a4.144 4.144 0 0 1-4.139-4.14V6.438A4.14 4.14 0 0 1 6.44 2.3h10.387v1.04H6.438a3.1 3.1 0 0 0-3.099 3.1v11.11c0 1.71 1.39 3.105 3.1 3.105h11.117c1.71 0 3.1-1.395 3.1-3.105v-1.754h1.04v1.754z"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function SimklLogo({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="1em"
|
|
height="1em"
|
|
viewBox="0 0 24 24"
|
|
className={className}
|
|
aria-hidden
|
|
>
|
|
<title>Simkl</title>
|
|
<path
|
|
fill="currentColor"
|
|
d="M3.84 0A3.83 3.83 0 0 0 0 3.84v16.32A3.83 3.83 0 0 0 3.84 24h16.32A3.83 3.83 0 0 0 24 20.16V3.84A3.83 3.83 0 0 0 20.16 0zm8.567 4.11q3.11 0 4.393.186q1.69.252 2.438.877q1.009.867 1.009 3.104q0 .241-.01.768h-4.234q-.021-.537-.074-.746q-.147-.615-.966-.692q-.725-.065-3.53-.066q-2.775 0-3.289.165q-.578.2-.578 1.024q0 .792.61.969q.514.143 4.633.275q3.73.11 4.76.275q1.04.165 1.654.495t.983.936q.556.892.557 2.873q0 2.212-.546 3.247q-.547 1.024-1.785 1.398q-1.219.374-6.71.374q-3.338 0-4.82-.187q-1.806-.22-2.593-.86q-.85-.684-1.008-1.93a10.5 10.5 0 0 1-.085-1.434v-.789H7.44q-.01 1.11.43 1.428q.232.151.525.203q.294.056 1.03.077a166 166 0 0 0 2.405.022q2.793-.01 3.234-.033q.83-.065 1.092-.23q.368-.242.368-1.077q0-.57-.231-.802q-.316-.318-1.503-.34q-.82 0-3.425-.132q-2.69-.133-3.488-.154q-2.08-.066-2.932-.505q-1.092-.56-1.429-1.91q-.189-.747-.189-1.956q0-2.547.925-3.59q.693-.79 2.102-1.044q1.271-.22 6.053-.22z"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function LetterboxdLogo({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="1em"
|
|
height="1em"
|
|
viewBox="0 0 24 24"
|
|
className={className}
|
|
aria-hidden
|
|
>
|
|
<title>Letterboxd</title>
|
|
<path
|
|
fill="currentColor"
|
|
d="M8.224 14.352a4.45 4.45 0 0 1-3.775 2.092C1.992 16.444 0 14.454 0 12s1.992-4.444 4.45-4.444c1.592 0 2.988.836 3.774 2.092c-.427.682-.673 1.488-.673 2.352s.246 1.67.673 2.352M15.101 12c0-.864.247-1.67.674-2.352c-.786-1.256-2.183-2.092-3.775-2.092s-2.989.836-3.775 2.092c.427.682.674 1.488.674 2.352s-.247 1.67-.674 2.352c.786 1.256 2.183 2.092 3.775 2.092s2.989-.836 3.775-2.092A4.4 4.4 0 0 1 15.1 12zm4.45-4.444a4.45 4.45 0 0 0-3.775 2.092c.427.682.673 1.488.673 2.352s-.246 1.67-.673 2.352a4.45 4.45 0 0 0 3.775 2.092C22.008 16.444 24 14.454 24 12s-1.992-4.444-4.45-4.444z"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
const SOURCES: SourceConfig[] = [
|
|
{
|
|
source: "trakt",
|
|
label: "Trakt",
|
|
description: msg`Connect your Trakt account or upload your Trakt export (ZIP or JSON)`,
|
|
accept: ".json,.zip",
|
|
icon: <TraktLogo className="text-primary size-4" />,
|
|
supportsOAuth: true,
|
|
},
|
|
{
|
|
source: "simkl",
|
|
label: "Simkl",
|
|
description: msg`Connect your Simkl account or upload a JSON export`,
|
|
accept: ".json",
|
|
icon: <SimklLogo className="text-primary size-4" />,
|
|
supportsOAuth: true,
|
|
},
|
|
{
|
|
source: "letterboxd",
|
|
label: "Letterboxd",
|
|
description: msg`Upload the ZIP export from your Letterboxd account settings`,
|
|
accept: ".zip",
|
|
icon: <LetterboxdLogo className="text-primary size-4" />,
|
|
supportsOAuth: false,
|
|
},
|
|
];
|
|
|
|
// ─── Types ──────────────────────────────────────────────────
|
|
|
|
interface ImportPreview {
|
|
data: NormalizedImport;
|
|
warnings: string[];
|
|
diagnostics?: {
|
|
unresolved: number;
|
|
unsupported: number;
|
|
};
|
|
blockingErrors?: string[];
|
|
stats: {
|
|
movies: number;
|
|
episodes: number;
|
|
watchlist: number;
|
|
ratings: number;
|
|
};
|
|
}
|
|
|
|
interface ImportResult {
|
|
imported: number;
|
|
skipped: number;
|
|
failed: number;
|
|
errors: string[];
|
|
warnings: string[];
|
|
}
|
|
|
|
interface DeviceCodeInfo {
|
|
device_code: string;
|
|
user_code: string;
|
|
verification_url: string;
|
|
expires_in: number;
|
|
interval: number;
|
|
}
|
|
|
|
type DialogStep =
|
|
| "choose" // OAuth sources: choose between Connect or Upload
|
|
| "device-code" // displaying device code, polling
|
|
| "fetching" // OAuth authorized, fetching data from provider
|
|
| "preview" // showing parsed preview with options
|
|
| "importing" // import in progress
|
|
| "done"; // showing results
|
|
|
|
// ─── Section ────────────────────────────────────────────────
|
|
|
|
export function ImportsSection() {
|
|
return (
|
|
<div className="space-y-2.5">
|
|
{SOURCES.map((config) => (
|
|
<ImportSourceCard key={config.source} config={config} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Source Card ─────────────────────────────────────────────
|
|
|
|
function ImportSourceCard({ config }: { config: SourceConfig }) {
|
|
const { i18n, t } = useLingui();
|
|
const queryClient = useQueryClient();
|
|
const { data: systemStatus } = useQuery(orpc.system.status.queryOptions());
|
|
const publicApiUrl = systemStatus?.publicApiUrl ?? "https://public-api.sofa.watch";
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [step, setStep] = useState<DialogStep>(config.supportsOAuth ? "choose" : "preview");
|
|
const [preview, setPreview] = useState<ImportPreview | null>(null);
|
|
const [result, setResult] = useState<ImportResult | null>(null);
|
|
const [options, setOptions] = useState({
|
|
importWatches: true,
|
|
importWatchlist: true,
|
|
importRatings: true,
|
|
});
|
|
|
|
// OAuth state
|
|
const [deviceCode, setDeviceCode] = useState<DeviceCodeInfo | null>(null);
|
|
const [oauthError, setOauthError] = useState<string | null>(null);
|
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const importAbortRef = useRef<AbortController | null>(null);
|
|
|
|
const parseMutation = useMutation(
|
|
orpc.imports.parseFile.mutationOptions({
|
|
onSuccess: (data) => {
|
|
setPreview(data as ImportPreview);
|
|
setStep("preview");
|
|
setDialogOpen(true);
|
|
},
|
|
onError: (err) => {
|
|
toast.error(getErrorMessage(err, t`Failed to parse file`));
|
|
},
|
|
}),
|
|
);
|
|
|
|
const parsePayloadMutation = useMutation(
|
|
orpc.imports.parsePayload.mutationOptions({
|
|
onSuccess: (data) => {
|
|
setPreview(data as ImportPreview);
|
|
setStep("preview");
|
|
},
|
|
onError: (err) => {
|
|
toast.error(getErrorMessage(err, t`Failed to parse import data`));
|
|
setStep("choose");
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Progress state
|
|
const [progress, setProgress] = useState<{
|
|
current: number;
|
|
total: number;
|
|
message: string;
|
|
} | null>(null);
|
|
|
|
function handleFileSelect(file: File) {
|
|
parseMutation.mutate(
|
|
{ source: config.source, file },
|
|
{
|
|
onSettled: () => {
|
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
async function handleImport() {
|
|
if (!preview) return;
|
|
setStep("importing");
|
|
setProgress(null);
|
|
|
|
const abort = new AbortController();
|
|
importAbortRef.current = abort;
|
|
|
|
try {
|
|
const job = await client.imports.createJob({
|
|
data: preview.data,
|
|
options,
|
|
});
|
|
|
|
const eventSource = await client.imports.jobEvents({ id: job.id }, { signal: abort.signal });
|
|
|
|
let receivedComplete = false;
|
|
|
|
for await (const event of eventSource) {
|
|
if (abort.signal.aborted) break;
|
|
if (event.type === "complete") {
|
|
receivedComplete = true;
|
|
setResult({
|
|
imported: event.job.importedCount,
|
|
skipped: event.job.skippedCount,
|
|
failed: event.job.failedCount,
|
|
errors: event.job.errors,
|
|
warnings: event.job.warnings,
|
|
});
|
|
setStep("done");
|
|
void invalidateTrackingQueries(queryClient);
|
|
const importedCount = event.job.importedCount;
|
|
const sourceLabel = config.label;
|
|
if (importedCount > 0) {
|
|
toast.success(t`Imported ${importedCount} items from ${sourceLabel}`);
|
|
}
|
|
} else if (event.type === "timeout") {
|
|
receivedComplete = true;
|
|
toast.info(t`Import is still running in the background. Check back later.`);
|
|
setStep("preview");
|
|
} else {
|
|
setProgress({
|
|
current: event.job.processedItems,
|
|
total: event.job.totalItems,
|
|
message: event.job.currentMessage ?? "",
|
|
});
|
|
}
|
|
}
|
|
|
|
// Stream ended without a complete/timeout event (e.g. connection dropped)
|
|
if (!receivedComplete && !abort.signal.aborted) {
|
|
try {
|
|
const finalJob = await client.imports.getJob({ id: job.id });
|
|
const isTerminal =
|
|
finalJob.status === "success" ||
|
|
finalJob.status === "error" ||
|
|
finalJob.status === "cancelled";
|
|
if (isTerminal) {
|
|
setResult({
|
|
imported: finalJob.importedCount,
|
|
skipped: finalJob.skippedCount,
|
|
failed: finalJob.failedCount,
|
|
errors: finalJob.errors,
|
|
warnings: finalJob.warnings,
|
|
});
|
|
setStep("done");
|
|
void invalidateTrackingQueries(queryClient);
|
|
} else {
|
|
toast.info(t`Import is still running in the background. Check back later.`);
|
|
setStep("preview");
|
|
}
|
|
} catch {
|
|
toast.error(t`Lost connection to import. Check status in settings.`);
|
|
setStep("preview");
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (abort.signal.aborted) return;
|
|
toast.error(getErrorMessage(err, t`Import failed`));
|
|
setStep("preview");
|
|
} finally {
|
|
importAbortRef.current = null;
|
|
}
|
|
}
|
|
|
|
// Clean up poll timer on unmount or dialog close
|
|
const stopPolling = useCallback(() => {
|
|
if (pollTimerRef.current) {
|
|
clearInterval(pollTimerRef.current);
|
|
pollTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
function handleClose() {
|
|
stopPolling();
|
|
importAbortRef.current?.abort();
|
|
importAbortRef.current = null;
|
|
setDialogOpen(false);
|
|
setStep(config.supportsOAuth ? "choose" : "preview");
|
|
setPreview(null);
|
|
setResult(null);
|
|
setDeviceCode(null);
|
|
setOauthError(null);
|
|
setOptions({
|
|
importWatches: true,
|
|
importWatchlist: true,
|
|
importRatings: true,
|
|
});
|
|
}
|
|
|
|
// ─── OAuth: Start device code flow ────────────────────────
|
|
|
|
async function startDeviceCodeFlow() {
|
|
setOauthError(null);
|
|
setStep("device-code");
|
|
const sourceLabel = config.label;
|
|
|
|
try {
|
|
const res = await fetch(`${publicApiUrl}/v1/import/${config.source}/device-code`, {
|
|
method: "POST",
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error("device-code request failed");
|
|
}
|
|
const data = (await res.json()) as DeviceCodeInfo;
|
|
setDeviceCode(data);
|
|
setDialogOpen(true);
|
|
|
|
// Start polling
|
|
startPolling(data);
|
|
} catch {
|
|
setOauthError(t`Failed to start ${sourceLabel} connection`);
|
|
setStep("choose");
|
|
setDialogOpen(true);
|
|
}
|
|
}
|
|
|
|
function startPolling(code: DeviceCodeInfo) {
|
|
stopPolling();
|
|
const interval = (code.interval || 5) * 1000;
|
|
const expiresAt = Date.now() + code.expires_in * 1000;
|
|
|
|
pollTimerRef.current = setInterval(async () => {
|
|
if (Date.now() > expiresAt) {
|
|
stopPolling();
|
|
setOauthError(t`Device code expired. Please try again.`);
|
|
setStep("choose");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`${publicApiUrl}/v1/import/${config.source}/poll`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ device_code: code.device_code }),
|
|
});
|
|
if (!res.ok) return;
|
|
|
|
const data = (await res.json()) as {
|
|
status: string;
|
|
data?: Record<string, unknown>;
|
|
error?: string;
|
|
};
|
|
|
|
if (data.status === "authorized" && data.data) {
|
|
stopPolling();
|
|
setStep("fetching");
|
|
parsePayloadMutation.mutate({
|
|
source: config.source as "trakt" | "simkl",
|
|
rawPayload: data.data,
|
|
});
|
|
} else if (data.status === "denied") {
|
|
stopPolling();
|
|
setOauthError(t`Authorization was denied. Please try again.`);
|
|
setStep("choose");
|
|
} else if (data.status === "expired") {
|
|
stopPolling();
|
|
setOauthError(t`Device code expired. Please try again.`);
|
|
setStep("choose");
|
|
} else if (data.status === "fetch_error") {
|
|
stopPolling();
|
|
setOauthError(
|
|
t`Authorization succeeded but failed to fetch your library. Please try again.`,
|
|
);
|
|
setStep("choose");
|
|
}
|
|
// "pending" → keep polling
|
|
} catch {
|
|
// Network error, keep polling
|
|
}
|
|
}, interval);
|
|
}
|
|
|
|
// Clean up on unmount
|
|
useEffect(() => stopPolling, [stopPolling]);
|
|
|
|
const isParsing = parseMutation.isPending;
|
|
|
|
return (
|
|
<Card>
|
|
<CardContent>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="flex items-start gap-3">
|
|
<div className="bg-primary/10 mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
|
|
{config.icon}
|
|
</div>
|
|
<div>
|
|
<CardTitle>{config.label}</CardTitle>
|
|
<CardDescription>{i18n._(config.description)}</CardDescription>
|
|
</div>
|
|
</div>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept={config.accept}
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) handleFileSelect(file);
|
|
}}
|
|
/>
|
|
<div className="flex gap-2">
|
|
{config.supportsOAuth && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setDialogOpen(true);
|
|
setStep("choose");
|
|
}}
|
|
>
|
|
<IconLink aria-hidden />
|
|
<Trans>Connect</Trans>
|
|
</Button>
|
|
)}
|
|
{!config.supportsOAuth && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={isParsing}
|
|
>
|
|
{isParsing ? <Spinner /> : <IconCloudUpload aria-hidden />}
|
|
{isParsing ? <Trans>Parsing...</Trans> : <Trans>Upload</Trans>}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
|
|
<Dialog open={dialogOpen} onOpenChange={(open) => !open && handleClose()}>
|
|
<DialogContent className="sm:max-w-md">
|
|
{step === "choose" && (
|
|
<ChooseStep
|
|
config={config}
|
|
oauthError={oauthError}
|
|
onConnect={() => startDeviceCodeFlow()}
|
|
onUpload={() => fileInputRef.current?.click()}
|
|
isParsing={isParsing}
|
|
onCancel={handleClose}
|
|
/>
|
|
)}
|
|
{step === "device-code" && (
|
|
<DeviceCodeStep source={config.label} deviceCode={deviceCode} onCancel={handleClose} />
|
|
)}
|
|
{step === "fetching" && <FetchingStep source={config.label} />}
|
|
{step === "preview" && preview && (
|
|
<PreviewStep
|
|
source={config.label}
|
|
preview={preview}
|
|
options={options}
|
|
setOptions={setOptions}
|
|
onImport={handleImport}
|
|
onCancel={handleClose}
|
|
/>
|
|
)}
|
|
{step === "importing" && <ImportingStep source={config.label} progress={progress} />}
|
|
{step === "done" && result && (
|
|
<DoneStep source={config.label} result={result} onClose={handleClose} />
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ─── Dialog Steps ───────────────────────────────────────────
|
|
|
|
function ChooseStep({
|
|
config,
|
|
oauthError,
|
|
onConnect,
|
|
onUpload,
|
|
isParsing,
|
|
onCancel,
|
|
}: {
|
|
config: SourceConfig;
|
|
oauthError: string | null;
|
|
onConnect: () => void;
|
|
onUpload: () => void;
|
|
isParsing: boolean;
|
|
onCancel: () => void;
|
|
}) {
|
|
const { t } = useLingui();
|
|
const sourceLabel = config.label;
|
|
const acceptFormat = formatAcceptLabel(config.accept);
|
|
return (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
<Trans>Import from {sourceLabel}</Trans>
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
<Trans>Choose how to import your {sourceLabel} data.</Trans>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-3 py-2">
|
|
{oauthError && (
|
|
<div className="bg-destructive/10 rounded-lg p-3">
|
|
<p className="text-destructive text-sm">{oauthError}</p>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
className="border-border/50 hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-4 text-start transition-colors"
|
|
onClick={onConnect}
|
|
>
|
|
<div className="bg-primary/10 flex h-10 w-10 shrink-0 items-center justify-center rounded-lg">
|
|
<IconLink aria-hidden className="text-primary size-5" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium">
|
|
<Trans>Connect with {sourceLabel}</Trans>
|
|
</p>
|
|
<p className="text-muted-foreground text-xs">
|
|
<Trans>Authorize Sofa to read your {sourceLabel} library. No password shared.</Trans>
|
|
</p>
|
|
</div>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
className="border-border/50 hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-4 text-start transition-colors"
|
|
onClick={() => {
|
|
onCancel();
|
|
// Small delay so the dialog closes before file picker opens
|
|
setTimeout(onUpload, 150);
|
|
}}
|
|
disabled={isParsing}
|
|
>
|
|
<div className="bg-primary/10 flex h-10 w-10 shrink-0 items-center justify-center rounded-lg">
|
|
<IconCloudUpload aria-hidden className="text-primary size-5" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium">
|
|
<Trans>Upload export file</Trans>
|
|
</p>
|
|
<p className="text-muted-foreground text-xs">
|
|
{t`Upload a ${acceptFormat} export from your ${sourceLabel} account settings.`}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<DialogClose render={<Button variant="outline" />} onClick={onCancel}>
|
|
<Trans>Cancel</Trans>
|
|
</DialogClose>
|
|
</DialogFooter>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function DeviceCodeStep({
|
|
source,
|
|
deviceCode,
|
|
onCancel,
|
|
}: {
|
|
source: string;
|
|
deviceCode: DeviceCodeInfo | null;
|
|
onCancel: () => void;
|
|
}) {
|
|
return (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
<Trans>Connect to {source}</Trans>
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
<Trans>Enter the code below on {source}'s website to authorize Sofa.</Trans>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{deviceCode ? (
|
|
<div className="space-y-4 py-4">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<p className="text-muted-foreground text-sm">
|
|
<Trans>Your code:</Trans>
|
|
</p>
|
|
<p className="bg-muted rounded-lg px-6 py-3 font-mono text-2xl font-bold tracking-widest">
|
|
{deviceCode.user_code}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex justify-center">
|
|
<a
|
|
href={deviceCode.verification_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary inline-flex items-center gap-1.5 text-sm font-medium underline-offset-4 hover:underline"
|
|
>
|
|
<IconLink aria-hidden className="size-4" />
|
|
<Trans>Open {source} to enter code</Trans>
|
|
</a>
|
|
</div>
|
|
|
|
<div className="text-muted-foreground flex items-center justify-center gap-2 text-xs">
|
|
<Spinner className="size-3" />
|
|
<Trans>Waiting for authorization...</Trans>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex justify-center py-8">
|
|
<Spinner className="size-6" />
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={onCancel}>
|
|
<Trans>Cancel</Trans>
|
|
</Button>
|
|
</DialogFooter>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function FetchingStep({ source }: { source: string }) {
|
|
return (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
<Trans>Connected to {source}</Trans>
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
<Trans>Fetching your library data from {source}...</Trans>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex flex-col items-center gap-3 py-8">
|
|
<Spinner className="size-8" />
|
|
<p className="text-muted-foreground text-sm">
|
|
<Trans>Retrieving your watch history, watchlist, and ratings...</Trans>
|
|
</p>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function PreviewStep({
|
|
source,
|
|
preview,
|
|
options,
|
|
setOptions,
|
|
onImport,
|
|
onCancel,
|
|
}: {
|
|
source: string;
|
|
preview: ImportPreview;
|
|
options: {
|
|
importWatches: boolean;
|
|
importWatchlist: boolean;
|
|
importRatings: boolean;
|
|
};
|
|
setOptions: (o: typeof options) => void;
|
|
onImport: () => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
const { t } = useLingui();
|
|
const { stats, warnings } = preview;
|
|
const totalItems =
|
|
(options.importWatches ? stats.movies + stats.episodes : 0) +
|
|
(options.importWatchlist ? stats.watchlist : 0) +
|
|
(options.importRatings ? stats.ratings : 0);
|
|
|
|
const movieCount = stats.movies;
|
|
const episodeCount = stats.episodes;
|
|
const watchlistCount = stats.watchlist;
|
|
const ratingCount = stats.ratings;
|
|
const unresolvedCount = preview.diagnostics?.unresolved ?? 0;
|
|
|
|
return (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
<Trans>Import from {source}</Trans>
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
<Trans>Review what was found and choose what to import.</Trans>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 py-2">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<StatBadge label={t`Movies`} count={movieCount} />
|
|
<StatBadge label={t`Episodes`} count={episodeCount} />
|
|
<StatBadge label={t`Watchlist`} count={watchlistCount} />
|
|
<StatBadge label={t`Ratings`} count={ratingCount} />
|
|
</div>
|
|
|
|
{preview.diagnostics && unresolvedCount > 0 && (
|
|
<div className="bg-muted/50 rounded-lg p-3">
|
|
<p className="text-muted-foreground text-xs">
|
|
<Trans>
|
|
{unresolvedCount} items have no external IDs and will be resolved by title search,
|
|
which may be less accurate.
|
|
</Trans>
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium">
|
|
<Trans>Import options</Trans>
|
|
</p>
|
|
<OptionCheckbox
|
|
label={t`Watch history`}
|
|
description={t`${movieCount} movies, ${episodeCount} episodes`}
|
|
checked={options.importWatches}
|
|
onChange={(v) => setOptions({ ...options, importWatches: v })}
|
|
/>
|
|
<OptionCheckbox
|
|
label={t`Watchlist`}
|
|
description={t`${watchlistCount} items`}
|
|
checked={options.importWatchlist}
|
|
onChange={(v) => setOptions({ ...options, importWatchlist: v })}
|
|
/>
|
|
<OptionCheckbox
|
|
label={t`Ratings`}
|
|
description={t`${ratingCount} ratings`}
|
|
checked={options.importRatings}
|
|
onChange={(v) => setOptions({ ...options, importRatings: v })}
|
|
/>
|
|
</div>
|
|
|
|
{warnings.length > 0 && (
|
|
<div className="rounded-lg bg-yellow-500/10 p-3">
|
|
<p className="mb-1 text-xs font-medium text-yellow-600">
|
|
<Trans>Warnings</Trans>
|
|
</p>
|
|
<ul className="space-y-0.5 text-xs text-yellow-600/80">
|
|
{warnings.map((w, i) => (
|
|
<li key={i}>{w}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<DialogClose render={<Button variant="outline" />} onClick={onCancel}>
|
|
<Trans>Cancel</Trans>
|
|
</DialogClose>
|
|
<Button onClick={onImport} disabled={totalItems === 0}>
|
|
<Trans>Import {totalItems} items</Trans>
|
|
</Button>
|
|
</DialogFooter>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ImportingStep({
|
|
source,
|
|
progress,
|
|
}: {
|
|
source: string;
|
|
progress: { current: number; total: number; message: string } | null;
|
|
}) {
|
|
const pct =
|
|
progress && progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : null;
|
|
|
|
return (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
<Trans>Importing from {source}</Trans>
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
<Trans>
|
|
This may take a few minutes for large libraries. Please don't close this tab.
|
|
</Trans>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex flex-col items-center gap-4 py-8">
|
|
<Progress value={pct} className="w-full" />
|
|
<div className="flex flex-col items-center gap-1 text-center">
|
|
{progress ? (
|
|
<>
|
|
<p className="text-sm font-medium">
|
|
{progress.current} / {progress.total}
|
|
</p>
|
|
<p className="text-muted-foreground max-w-[300px] truncate text-xs">
|
|
{progress.message}
|
|
</p>
|
|
</>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<Spinner className="size-3" />
|
|
<p className="text-muted-foreground text-sm">
|
|
<Trans>Starting import...</Trans>
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function DoneStep({
|
|
source,
|
|
result,
|
|
onClose,
|
|
}: {
|
|
source: string;
|
|
result: ImportResult;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useLingui();
|
|
const errorCount = result.errors.length;
|
|
const warningCount = result.warnings.length;
|
|
const remainingErrors = errorCount - 50;
|
|
return (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
<Trans>Import complete</Trans>
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
<Trans>Finished importing from {source}.</Trans>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 py-2">
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<StatBadge label={t`Imported`} count={result.imported} />
|
|
<StatBadge label={t`Skipped`} count={result.skipped} />
|
|
<StatBadge label={t`Failed`} count={result.failed} />
|
|
</div>
|
|
|
|
{errorCount > 0 && (
|
|
<div className="bg-destructive/10 max-h-40 overflow-y-auto rounded-lg p-3">
|
|
<p className="text-destructive mb-1 text-xs font-medium">
|
|
<Trans>Errors ({errorCount})</Trans>
|
|
</p>
|
|
<ul className="text-destructive/80 space-y-0.5 text-xs">
|
|
{result.errors.slice(0, 50).map((e, i) => (
|
|
<li key={i}>{e}</li>
|
|
))}
|
|
{errorCount > 50 && (
|
|
<li>
|
|
<Trans>...and {remainingErrors} more</Trans>
|
|
</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{warningCount > 0 && (
|
|
<div className="max-h-32 overflow-y-auto rounded-lg bg-yellow-500/10 p-3">
|
|
<p className="mb-1 text-xs font-medium text-yellow-600">
|
|
<Trans>Warnings ({warningCount})</Trans>
|
|
</p>
|
|
<ul className="space-y-0.5 text-xs text-yellow-600/80">
|
|
{result.warnings.slice(0, 20).map((w, i) => (
|
|
<li key={i}>{w}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button onClick={onClose}>
|
|
<Trans>Done</Trans>
|
|
</Button>
|
|
</DialogFooter>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────
|
|
|
|
function StatBadge({ label, count }: { label: string; count: number }) {
|
|
return (
|
|
<div className="bg-muted/50 rounded-lg p-2.5 text-center">
|
|
<p className="text-lg leading-none font-semibold">{count}</p>
|
|
<p className="text-muted-foreground mt-1 text-xs">{label}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OptionCheckbox({
|
|
label,
|
|
description,
|
|
checked,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
description: string;
|
|
checked: boolean;
|
|
onChange: (v: boolean) => void;
|
|
}) {
|
|
const id = `import-opt-${label}`;
|
|
return (
|
|
<div className="flex items-center gap-3">
|
|
<Checkbox id={id} checked={checked} onCheckedChange={onChange} />
|
|
<div>
|
|
<Label htmlFor={id} className="text-sm">
|
|
{label}
|
|
</Label>
|
|
<p className="text-muted-foreground text-xs">{description}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|