- Integrated `next-intl` for toast messages and locale handling in various components, including `Providers`, `CanvasUserMenu`, and `CreditOverview`. - Replaced hardcoded strings with translation keys to enhance localization capabilities. - Updated `RootLayout` to dynamically set the language attribute based on the user's locale. - Ensured consistent user feedback through localized toast messages in actions such as sign-out, canvas operations, and billing notifications.
133 lines
4.2 KiB
TypeScript
133 lines
4.2 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useState } from "react";
|
|
import { useReactFlow } from "@xyflow/react";
|
|
import { useAction } from "convex/react";
|
|
import { useTranslations } from "next-intl";
|
|
import JSZip from "jszip";
|
|
import { Archive, Loader2 } from "lucide-react";
|
|
import { api } from "@/convex/_generated/api";
|
|
import type { Id } from "@/convex/_generated/dataModel";
|
|
import { toast } from "@/lib/toast";
|
|
|
|
interface ExportButtonProps {
|
|
canvasName?: string;
|
|
}
|
|
|
|
export function ExportButton({ canvasName = "canvas" }: ExportButtonProps) {
|
|
const t = useTranslations('toasts');
|
|
const { getNodes } = useReactFlow();
|
|
const exportFrame = useAction(api.export.exportFrame);
|
|
const [isExporting, setIsExporting] = useState(false);
|
|
const [progress, setProgress] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const handleZipExport = useCallback(async () => {
|
|
if (isExporting) return;
|
|
setIsExporting(true);
|
|
setError(null);
|
|
|
|
const NO_FRAMES = "NO_FRAMES";
|
|
|
|
const runExport = async () => {
|
|
const nodes = getNodes();
|
|
const frameNodes = nodes.filter((node) => node.type === "frame");
|
|
|
|
if (frameNodes.length === 0) {
|
|
throw new Error(NO_FRAMES);
|
|
}
|
|
|
|
const zip = new JSZip();
|
|
|
|
for (let i = 0; i < frameNodes.length; i += 1) {
|
|
const frame = frameNodes[i];
|
|
const frameLabel =
|
|
(frame.data as { label?: string }).label?.trim() || `frame-${i + 1}`;
|
|
|
|
setProgress(`Exporting ${frameLabel} (${i + 1}/${frameNodes.length})...`);
|
|
|
|
const result = await exportFrame({
|
|
frameNodeId: frame.id as Id<"nodes">,
|
|
});
|
|
|
|
const response = await fetch(result.url);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch export for ${frameLabel}`);
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
zip.file(`${frameLabel}.png`, blob);
|
|
}
|
|
|
|
setProgress("Packing ZIP...");
|
|
const zipBlob = await zip.generateAsync({ type: "blob" });
|
|
const url = URL.createObjectURL(zipBlob);
|
|
|
|
const anchor = document.createElement("a");
|
|
anchor.href = url;
|
|
anchor.download = `${canvasName}-export.zip`;
|
|
anchor.click();
|
|
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
try {
|
|
await toast.promise(runExport(), {
|
|
loading: t('export.exportingFrames'),
|
|
success: t('export.zipReady'),
|
|
error: (err) => {
|
|
const m = err instanceof Error ? err.message : "";
|
|
if (m === NO_FRAMES) return t('export.noFramesOnCanvasTitle');
|
|
if (m.includes("No images found")) return t('export.frameEmptyTitle');
|
|
return t('export.exportFailed');
|
|
},
|
|
description: {
|
|
error: (err) => {
|
|
const m = err instanceof Error ? err.message : "";
|
|
if (m === NO_FRAMES) return t('export.noFramesOnCanvasDesc');
|
|
if (m.includes("No images found")) return t('export.frameEmptyDesc');
|
|
return m || undefined;
|
|
},
|
|
},
|
|
});
|
|
} catch (err) {
|
|
const m = err instanceof Error ? err.message : "";
|
|
if (m === NO_FRAMES) {
|
|
setError(t('export.noFramesOnCanvasDesc'));
|
|
} else if (m.includes("No images found")) {
|
|
setError(t('export.frameEmptyDesc'));
|
|
} else {
|
|
setError(m || t('export.exportFailed'));
|
|
}
|
|
} finally {
|
|
setIsExporting(false);
|
|
setProgress(null);
|
|
}
|
|
}, [t, canvasName, exportFrame, getNodes, isExporting]);
|
|
|
|
return (
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => void handleZipExport()}
|
|
disabled={isExporting}
|
|
title="Export all frames as ZIP"
|
|
className="flex items-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm font-medium transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
|
type="button"
|
|
>
|
|
{isExporting ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Archive className="h-4 w-4" />
|
|
)}
|
|
{progress ?? "Export ZIP"}
|
|
</button>
|
|
|
|
{error && (
|
|
<p className="absolute left-0 top-full mt-1 whitespace-nowrap text-xs text-destructive">
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|