mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-06 07:03:52 +02:00
feat: add onboarding
This commit is contained in:
@@ -280,9 +280,40 @@ export function AccountPage({
|
||||
<p className="mt-0.5">{user.planPeriod}</p>
|
||||
</div>
|
||||
)}
|
||||
{typeof user.deviceOrdinal === "number" && (
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t("account.fields.device")}
|
||||
</p>
|
||||
<p className="mt-0.5">
|
||||
{t("account.deviceOrdinal", {
|
||||
ordinal: user.deviceOrdinal,
|
||||
count: user.deviceCount ?? user.deviceOrdinal,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoggedIn &&
|
||||
user &&
|
||||
user.plan !== "free" &&
|
||||
user.isPrimaryDevice === false && (
|
||||
<p className="text-xs text-warning">
|
||||
{t("account.automationPrimaryOnly")}
|
||||
</p>
|
||||
)}
|
||||
{isLoggedIn &&
|
||||
user &&
|
||||
user.plan !== "free" &&
|
||||
user.isPrimaryDevice === true &&
|
||||
(user.deviceCount ?? 1) > 1 && (
|
||||
<p className="text-xs text-success">
|
||||
{t("account.automationActiveHere")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function AppUpdateToast({
|
||||
return (
|
||||
<div className="flex items-start p-4 w-full max-w-md rounded-lg border shadow-lg bg-card border-border text-card-foreground">
|
||||
<div className="mr-3 mt-0.5">
|
||||
<LuCheckCheck className="flex-shrink-0 size-5" />
|
||||
<LuCheckCheck className="shrink-0 size-5" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { I18nProvider } from "@/components/i18n-provider";
|
||||
import { OnboardingProvider } from "@/components/onboarding-provider";
|
||||
import { CustomThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
@@ -17,7 +18,9 @@ export function ClientProviders({ children }: { children: React.ReactNode }) {
|
||||
<I18nProvider>
|
||||
<CustomThemeProvider>
|
||||
<WindowDragArea />
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<OnboardingProvider>{children}</OnboardingProvider>
|
||||
</TooltipProvider>
|
||||
<Toaster />
|
||||
</CustomThemeProvider>
|
||||
</I18nProvider>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
|
||||
import { LuCheck, LuChevronsUpDown, LuLoaderCircle } from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
|
||||
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
||||
@@ -307,6 +307,10 @@ export function CreateProfileDialog({
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadSupportedBrowsers();
|
||||
// Load downloaded versions for both anti-detect browsers up front so the
|
||||
// selection-screen availability gate is accurate before either is picked.
|
||||
void loadDownloadedVersions("wayfern");
|
||||
void loadDownloadedVersions("camoufox");
|
||||
// Load release types when a browser is selected
|
||||
if (selectedBrowser) {
|
||||
void loadReleaseTypes(selectedBrowser);
|
||||
@@ -320,6 +324,7 @@ export function CreateProfileDialog({
|
||||
isOpen,
|
||||
loadSupportedBrowsers,
|
||||
loadReleaseTypes,
|
||||
loadDownloadedVersions,
|
||||
checkAndDownloadGeoIPDatabase,
|
||||
selectedBrowser,
|
||||
]);
|
||||
@@ -405,6 +410,7 @@ export function CreateProfileDialog({
|
||||
const resolvedProxyId = isVpnSelection ? undefined : selectedProxyId;
|
||||
const resolvedVpnId =
|
||||
isVpnSelection && selectedProxyId ? selectedProxyId.slice(4) : undefined;
|
||||
|
||||
const passwordToSet =
|
||||
enablePassword && !ephemeral && password.length >= PASSWORD_MIN_LEN
|
||||
? password
|
||||
@@ -585,7 +591,7 @@ export function CreateProfileDialog({
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="w-[380px] max-w-[380px] max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>
|
||||
{currentStep === "browser-selection"
|
||||
? t("createProfile.title")
|
||||
@@ -618,23 +624,30 @@ export function CreateProfileDialog({
|
||||
onClick={() => {
|
||||
handleBrowserSelect("wayfern");
|
||||
}}
|
||||
disabled={!getCreatableVersion("wayfern")}
|
||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
||||
variant="outline"
|
||||
>
|
||||
<div className="flex justify-center items-center size-8">
|
||||
{(() => {
|
||||
const IconComponent = getBrowserIcon("wayfern");
|
||||
return IconComponent ? (
|
||||
<IconComponent className="size-6" />
|
||||
) : null;
|
||||
})()}
|
||||
{isBrowserCurrentlyDownloading("wayfern") ? (
|
||||
<LuLoaderCircle className="size-6 animate-spin" />
|
||||
) : (
|
||||
(() => {
|
||||
const IconComponent = getBrowserIcon("wayfern");
|
||||
return IconComponent ? (
|
||||
<IconComponent className="size-6" />
|
||||
) : null;
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">
|
||||
{t("createProfile.chromiumLabel")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("createProfile.chromiumSubtitle")}
|
||||
{isBrowserCurrentlyDownloading("wayfern")
|
||||
? t("createProfile.downloadingSubtitle")
|
||||
: t("createProfile.chromiumSubtitle")}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
@@ -644,26 +657,41 @@ export function CreateProfileDialog({
|
||||
onClick={() => {
|
||||
handleBrowserSelect("camoufox");
|
||||
}}
|
||||
disabled={!getCreatableVersion("camoufox")}
|
||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
||||
variant="outline"
|
||||
>
|
||||
<div className="flex justify-center items-center size-8">
|
||||
{(() => {
|
||||
const IconComponent = getBrowserIcon("camoufox");
|
||||
return IconComponent ? (
|
||||
<IconComponent className="size-6" />
|
||||
) : null;
|
||||
})()}
|
||||
{isBrowserCurrentlyDownloading("camoufox") ? (
|
||||
<LuLoaderCircle className="size-6 animate-spin" />
|
||||
) : (
|
||||
(() => {
|
||||
const IconComponent =
|
||||
getBrowserIcon("camoufox");
|
||||
return IconComponent ? (
|
||||
<IconComponent className="size-6" />
|
||||
) : null;
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">
|
||||
{t("createProfile.firefoxLabel")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("createProfile.firefoxSubtitle")}
|
||||
{isBrowserCurrentlyDownloading("camoufox")
|
||||
? t("createProfile.downloadingSubtitle")
|
||||
: t("createProfile.firefoxSubtitle")}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{!getCreatableVersion("wayfern") &&
|
||||
!getCreatableVersion("camoufox") && (
|
||||
<p className="pt-2 text-sm text-center text-muted-foreground">
|
||||
{t("createProfile.browsersDownloading")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -867,7 +895,7 @@ export function CreateProfileDialog({
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!isBrowserCurrentlyDownloading("wayfern") &&
|
||||
!isBrowserVersionAvailable("wayfern") &&
|
||||
!getCreatableVersion("wayfern") &&
|
||||
getBestAvailableVersion("wayfern") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -899,17 +927,53 @@ export function CreateProfileDialog({
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!isBrowserCurrentlyDownloading("wayfern") &&
|
||||
isBrowserVersionAvailable("wayfern") && (
|
||||
getCreatableVersion("wayfern") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
✓{" "}
|
||||
{t("createProfile.version.available", {
|
||||
browser: "Wayfern",
|
||||
version:
|
||||
getBestAvailableVersion("wayfern")
|
||||
?.version,
|
||||
getCreatableVersion("wayfern")?.version,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!isBrowserCurrentlyDownloading("wayfern") &&
|
||||
getCreatableVersion("wayfern") &&
|
||||
!isBrowserVersionAvailable("wayfern") &&
|
||||
getBestAvailableVersion("wayfern") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<p className="flex-1 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"createProfile.version.upgradeAvailable",
|
||||
{
|
||||
browser: "Wayfern",
|
||||
version:
|
||||
getBestAvailableVersion("wayfern")
|
||||
?.version,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => {
|
||||
void handleDownload("wayfern");
|
||||
}}
|
||||
isLoading={isBrowserCurrentlyDownloading(
|
||||
"wayfern",
|
||||
)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isBrowserCurrentlyDownloading(
|
||||
"wayfern",
|
||||
)}
|
||||
>
|
||||
{isBrowserCurrentlyDownloading("wayfern")
|
||||
? t("common.buttons.downloading")
|
||||
: t("common.buttons.download")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading("wayfern") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
{t("createProfile.version.downloading", {
|
||||
@@ -927,7 +991,7 @@ export function CreateProfileDialog({
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
profileVersion={
|
||||
getBestAvailableVersion("wayfern")?.version
|
||||
getCreatableVersion("wayfern")?.version
|
||||
}
|
||||
profileBrowser="wayfern"
|
||||
/>
|
||||
@@ -975,7 +1039,7 @@ export function CreateProfileDialog({
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!isBrowserCurrentlyDownloading("camoufox") &&
|
||||
!isBrowserVersionAvailable("camoufox") &&
|
||||
!getCreatableVersion("camoufox") &&
|
||||
getBestAvailableVersion("camoufox") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -1007,17 +1071,53 @@ export function CreateProfileDialog({
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!isBrowserCurrentlyDownloading("camoufox") &&
|
||||
isBrowserVersionAvailable("camoufox") && (
|
||||
getCreatableVersion("camoufox") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
✓{" "}
|
||||
{t("createProfile.version.available", {
|
||||
browser: "Camoufox",
|
||||
version:
|
||||
getBestAvailableVersion("camoufox")
|
||||
?.version,
|
||||
getCreatableVersion("camoufox")?.version,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!isBrowserCurrentlyDownloading("camoufox") &&
|
||||
getCreatableVersion("camoufox") &&
|
||||
!isBrowserVersionAvailable("camoufox") &&
|
||||
getBestAvailableVersion("camoufox") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<p className="flex-1 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"createProfile.version.upgradeAvailable",
|
||||
{
|
||||
browser: "Camoufox",
|
||||
version:
|
||||
getBestAvailableVersion("camoufox")
|
||||
?.version,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => {
|
||||
void handleDownload("camoufox");
|
||||
}}
|
||||
isLoading={isBrowserCurrentlyDownloading(
|
||||
"camoufox",
|
||||
)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isBrowserCurrentlyDownloading(
|
||||
"camoufox",
|
||||
)}
|
||||
>
|
||||
{isBrowserCurrentlyDownloading("camoufox")
|
||||
? t("common.buttons.downloading")
|
||||
: t("common.buttons.download")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading("camoufox") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
{t("createProfile.version.downloading", {
|
||||
@@ -1045,7 +1145,7 @@ export function CreateProfileDialog({
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
profileVersion={
|
||||
getBestAvailableVersion("camoufox")?.version
|
||||
getCreatableVersion("camoufox")?.version
|
||||
}
|
||||
profileBrowser="camoufox"
|
||||
/>
|
||||
@@ -1077,7 +1177,7 @@ export function CreateProfileDialog({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Retry
|
||||
{t("common.buttons.retry")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -1086,7 +1186,7 @@ export function CreateProfileDialog({
|
||||
!isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
) &&
|
||||
!isBrowserVersionAvailable(selectedBrowser) &&
|
||||
!getCreatableVersion(selectedBrowser) &&
|
||||
getBestAvailableVersion(selectedBrowser) && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -1122,18 +1222,15 @@ export function CreateProfileDialog({
|
||||
!isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
) &&
|
||||
isBrowserVersionAvailable(
|
||||
selectedBrowser,
|
||||
) && (
|
||||
getCreatableVersion(selectedBrowser) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
✓{" "}
|
||||
{t(
|
||||
"createProfile.version.latestAvailable",
|
||||
{
|
||||
version:
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
)?.version,
|
||||
getCreatableVersion(selectedBrowser)
|
||||
?.version,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
@@ -1432,7 +1529,7 @@ export function CreateProfileDialog({
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fetching available versions...
|
||||
{t("createProfile.version.fetching")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1458,7 +1555,7 @@ export function CreateProfileDialog({
|
||||
!isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
) &&
|
||||
!isBrowserVersionAvailable(selectedBrowser) &&
|
||||
!getCreatableVersion(selectedBrowser) &&
|
||||
getBestAvailableVersion(selectedBrowser) && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -1494,16 +1591,15 @@ export function CreateProfileDialog({
|
||||
!isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
) &&
|
||||
isBrowserVersionAvailable(selectedBrowser) && (
|
||||
getCreatableVersion(selectedBrowser) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
✓{" "}
|
||||
{t(
|
||||
"createProfile.version.latestAvailable",
|
||||
{
|
||||
version:
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
)?.version,
|
||||
getCreatableVersion(selectedBrowser)
|
||||
?.version,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
@@ -1701,7 +1797,7 @@ export function CreateProfileDialog({
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter className="flex-shrink-0 pt-4 border-t">
|
||||
<DialogFooter className="shrink-0 pt-4 border-t">
|
||||
{currentStep === "browser-config" ? (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={handleBack}>
|
||||
|
||||
@@ -174,42 +174,38 @@ function formatEtaCompact(seconds: number): string {
|
||||
function getToastIcon(type: ToastProps["type"], stage?: string) {
|
||||
switch (type) {
|
||||
case "success":
|
||||
return <LuCheckCheck className="flex-shrink-0 size-4 text-foreground" />;
|
||||
return <LuCheckCheck className="shrink-0 size-4 text-foreground" />;
|
||||
case "error":
|
||||
return (
|
||||
<LuTriangleAlert className="flex-shrink-0 size-4 text-foreground" />
|
||||
);
|
||||
return <LuTriangleAlert className="shrink-0 size-4 text-foreground" />;
|
||||
case "download":
|
||||
if (stage === "completed") {
|
||||
return (
|
||||
<LuCheckCheck className="flex-shrink-0 size-4 text-foreground" />
|
||||
);
|
||||
return <LuCheckCheck className="shrink-0 size-4 text-foreground" />;
|
||||
}
|
||||
return <LuDownload className="flex-shrink-0 size-4 text-foreground" />;
|
||||
return <LuDownload className="shrink-0 size-4 text-foreground" />;
|
||||
|
||||
case "version-update":
|
||||
return (
|
||||
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
|
||||
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
|
||||
);
|
||||
case "fetching":
|
||||
return (
|
||||
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
|
||||
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
|
||||
);
|
||||
case "twilight-update":
|
||||
return (
|
||||
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
|
||||
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
|
||||
);
|
||||
case "sync-progress":
|
||||
return (
|
||||
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
|
||||
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
|
||||
);
|
||||
case "loading":
|
||||
return (
|
||||
<div className="flex-shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
|
||||
<div className="shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="flex-shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
|
||||
<div className="shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -232,7 +228,7 @@ export function UnifiedToast(props: ToastProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
|
||||
className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
aria-label={t("common.buttons.cancel")}
|
||||
>
|
||||
<LuX className="size-3" />
|
||||
|
||||
@@ -1129,10 +1129,10 @@ export function ExtensionManagementDialog({
|
||||
{limitedMode && (
|
||||
<>
|
||||
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
|
||||
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 left-0 w-6 bg-linear-to-r from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 right-0 w-6 bg-linear-to-l from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 top-0 h-6 bg-linear-to-b from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-[3]">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
|
||||
<ProBadge />
|
||||
|
||||
@@ -148,10 +148,10 @@ export function GroupBadges({
|
||||
return (
|
||||
<div className="relative mb-4">
|
||||
{showLeftFade && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-background to-transparent pointer-events-none z-10" />
|
||||
<div className="absolute left-0 top-0 bottom-0 w-8 bg-linear-to-r from-background to-transparent pointer-events-none z-10" />
|
||||
)}
|
||||
{showRightFade && (
|
||||
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-background to-transparent pointer-events-none z-10" />
|
||||
<div className="absolute right-0 top-0 bottom-0 w-8 bg-linear-to-l from-background to-transparent pointer-events-none z-10" />
|
||||
)}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
@@ -165,7 +165,7 @@ export function GroupBadges({
|
||||
<Badge
|
||||
key={group.id}
|
||||
variant={selectedGroupId === group.id ? "default" : "secondary"}
|
||||
className="flex gap-2 items-center px-3 py-1 transition-colors cursor-pointer dark:hover:bg-primary/60 hover:bg-primary/80 flex-shrink-0"
|
||||
className="flex gap-2 items-center px-3 py-1 transition-colors cursor-pointer dark:hover:bg-primary/60 hover:bg-primary/80 shrink-0"
|
||||
onClick={(e) => {
|
||||
if (hasMovedRef.current || clickBlockedRef.current) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -321,6 +321,7 @@ const HomeHeader = ({
|
||||
<span className="shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
data-onborda="create-profile"
|
||||
onClick={() => {
|
||||
onCreateProfileDialogOpen(true);
|
||||
}}
|
||||
|
||||
@@ -303,7 +303,7 @@ export function ImportProfileDialog({
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("importProfile.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
)}
|
||||
@@ -604,7 +604,7 @@ export function ImportProfileDialog({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 flex gap-2 items-center justify-end",
|
||||
"shrink-0 flex gap-2 items-center justify-end",
|
||||
subPage ? "pt-2 border-t border-border" : undefined,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import type { CardComponentProps } from "onborda";
|
||||
import { useOnborda } from "onborda";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ONBOARDING_TOUR_FINISHED_EVENT } from "@/lib/onboarding-signal";
|
||||
|
||||
// Custom Onborda card, themed with the app's CSS variables. Finishing the last
|
||||
// step emits ONBOARDING_TOUR_FINISHED_EVENT so the page can show the celebratory
|
||||
// thank-you dialog (skipping early does not emit it).
|
||||
export function OnboardingCard({
|
||||
step,
|
||||
currentStep,
|
||||
totalSteps,
|
||||
nextStep,
|
||||
prevStep,
|
||||
arrow,
|
||||
}: CardComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const { closeOnborda } = useOnborda();
|
||||
|
||||
const isFirst = currentStep === 0;
|
||||
const isLast = currentStep === totalSteps - 1;
|
||||
// This step is completed by clicking the highlighted element (the "New"
|
||||
// button), not by a "Next" button — advancing manually would jump to a step
|
||||
// whose target doesn't exist yet and block the button. So hide "Next" here.
|
||||
const requiresAction = step.selector === '[data-onborda="create-profile"]';
|
||||
|
||||
return (
|
||||
<div className="relative p-4 w-80 max-w-[90vw] rounded-lg border shadow-lg bg-popover text-popover-foreground">
|
||||
<div className="flex gap-2 items-start justify-between">
|
||||
<h3 className="text-sm font-semibold leading-tight">{step.title}</h3>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||
{currentStep + 1}/{totalSteps}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{step.content}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center justify-between mt-4">
|
||||
{isLast ? (
|
||||
<span />
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs h-7 px-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
closeOnborda();
|
||||
}}
|
||||
>
|
||||
{t("onboarding.buttons.skip")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
{!isFirst && !isLast && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7 px-2.5"
|
||||
onClick={() => {
|
||||
prevStep();
|
||||
}}
|
||||
>
|
||||
{t("onboarding.buttons.back")}
|
||||
</Button>
|
||||
)}
|
||||
{isLast ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="text-xs h-7 px-3"
|
||||
onClick={() => {
|
||||
closeOnborda();
|
||||
window.dispatchEvent(new Event(ONBOARDING_TOUR_FINISHED_EVENT));
|
||||
}}
|
||||
>
|
||||
{t("onboarding.buttons.finish")}
|
||||
</Button>
|
||||
) : requiresAction ? null : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="text-xs h-7 px-3"
|
||||
onClick={() => {
|
||||
nextStep();
|
||||
}}
|
||||
>
|
||||
{t("onboarding.buttons.next")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-popover">{arrow}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { Onborda, type OnbordaProps, OnbordaProvider } from "onborda";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { OnboardingCard } from "@/components/onboarding-card";
|
||||
|
||||
// Name of the first-run product tour. Referenced by the trigger logic in
|
||||
// `src/app/page.tsx` via `startOnborda(ONBOARDING_TOUR)`.
|
||||
export const ONBOARDING_TOUR = "donut-onboarding";
|
||||
|
||||
export function OnboardingProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tours: OnbordaProps["steps"] = [
|
||||
{
|
||||
tour: ONBOARDING_TOUR,
|
||||
steps: [
|
||||
{
|
||||
icon: null,
|
||||
title: t("onboarding.steps.createProfile.title"),
|
||||
content: t("onboarding.steps.createProfile.content"),
|
||||
selector: '[data-onborda="create-profile"]',
|
||||
// The "New" button sits in the top-right corner; "bottom-right"
|
||||
// anchors the card's right edge to it so the card extends left/down
|
||||
// and stays on-screen instead of overflowing the right viewport edge.
|
||||
side: "bottom-right",
|
||||
showControls: true,
|
||||
pointerPadding: 8,
|
||||
pointerRadius: 10,
|
||||
},
|
||||
{
|
||||
icon: null,
|
||||
title: t("onboarding.steps.dnsBlocking.title"),
|
||||
content: t("onboarding.steps.dnsBlocking.content"),
|
||||
selector: '[data-onborda="dns-blocklist"]',
|
||||
// The DNS dropdown sits in the right-hand columns. A centered "bottom"
|
||||
// card runs off the right edge; "bottom-right" anchors the card's right
|
||||
// edge to the dropdown and extends it left/down, keeping it fully
|
||||
// on-screen with its arrow pointing up at the option.
|
||||
side: "bottom-right",
|
||||
showControls: true,
|
||||
pointerPadding: 6,
|
||||
pointerRadius: 8,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<OnbordaProvider>
|
||||
<Onborda
|
||||
steps={tours}
|
||||
cardComponent={OnboardingCard}
|
||||
interact
|
||||
shadowRgb="0,0,0"
|
||||
shadowOpacity="0.6"
|
||||
>
|
||||
{children}
|
||||
</Onborda>
|
||||
</OnbordaProvider>
|
||||
);
|
||||
}
|
||||
@@ -131,9 +131,9 @@ export function PermissionDialog({
|
||||
const getPermissionIcon = (type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return <BsMic className="size-8" />;
|
||||
return <BsMic className="size-5 shrink-0" />;
|
||||
case "camera":
|
||||
return <BsCamera className="size-8" />;
|
||||
return <BsCamera className="size-5 shrink-0" />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -195,13 +195,11 @@ export function PermissionDialog({
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader className="text-center">
|
||||
<div className="flex justify-center items-center mx-auto mb-4 size-16 bg-primary/15 rounded-full">
|
||||
<DialogTitle className="flex items-center justify-center gap-2 text-xl">
|
||||
{getPermissionIcon(permissionType)}
|
||||
</div>
|
||||
<DialogTitle className="text-xl">
|
||||
{getPermissionTitle(permissionType)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-base">
|
||||
<DialogDescription className="text-base text-pretty">
|
||||
{getPermissionDescription(permissionType)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -441,6 +441,7 @@ function DnsCell({
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-onborda="dns-blocklist"
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-1.5 h-7 px-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 rounded transition-colors duration-100 w-full text-left disabled:opacity-50"
|
||||
title={
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
LuGroup,
|
||||
LuKey,
|
||||
LuLink,
|
||||
LuLock,
|
||||
LuLockOpen,
|
||||
LuPlus,
|
||||
LuPuzzle,
|
||||
@@ -341,7 +342,9 @@ export function ProfileInfoDialog({
|
||||
onClick: () => {
|
||||
handleAction(() => onConfigureCamoufox?.(profile));
|
||||
},
|
||||
disabled: isDisabled,
|
||||
// Viewing and editing fingerprints both require an active paid plan.
|
||||
disabled: isDisabled || !crossOsUnlocked,
|
||||
proBadge: !crossOsUnlocked,
|
||||
runningBadge: isRunning,
|
||||
hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox,
|
||||
},
|
||||
@@ -481,6 +484,9 @@ export function ProfileInfoDialog({
|
||||
hideClose
|
||||
className="sm:max-w-3xl w-[720px] max-w-[720px] h-[480px] max-h-[480px] flex flex-col p-0 gap-0 overflow-hidden"
|
||||
>
|
||||
{/* The dialog renders its own custom header, so the accessible title is
|
||||
visually hidden but present for screen readers (Radix requires it). */}
|
||||
<DialogTitle className="sr-only">{t("profileInfo.title")}</DialogTitle>
|
||||
<ProfileInfoLayout
|
||||
profile={profile}
|
||||
ProfileIcon={ProfileIcon}
|
||||
@@ -888,6 +894,7 @@ function ProfileInfoLayout({
|
||||
// proBadge state. Default to false if action missing.
|
||||
fingerprintAction && !fingerprintAction.proBadge,
|
||||
)}
|
||||
onSaved={onClose}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
@@ -1586,11 +1593,13 @@ function FingerprintSectionInline({
|
||||
profile,
|
||||
isDisabled,
|
||||
crossOsUnlocked,
|
||||
onSaved,
|
||||
t,
|
||||
}: {
|
||||
profile: BrowserProfile;
|
||||
isDisabled: boolean;
|
||||
crossOsUnlocked: boolean;
|
||||
onSaved: () => void;
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
const [camoufoxConfig, setCamoufoxConfig] = React.useState<CamoufoxConfig>(
|
||||
@@ -1629,6 +1638,23 @@ function FingerprintSectionInline({
|
||||
);
|
||||
}
|
||||
|
||||
// Viewing and editing fingerprints both require an active paid plan
|
||||
// (`crossOsUnlocked` is that paid flag here). Render a locked state instead of
|
||||
// the editor so free users can neither see nor change the fingerprint.
|
||||
if (!crossOsUnlocked) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border p-6 text-center">
|
||||
<LuLock className="size-4 shrink-0 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
{t("profileInfo.fingerprint.lockedTitle")}
|
||||
</h3>
|
||||
<p className="max-w-[48ch] text-sm text-pretty text-muted-foreground">
|
||||
{t("profileInfo.fingerprint.lockedDescription")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const onCamoufoxChange = (key: keyof CamoufoxConfig, value: unknown) => {
|
||||
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
|
||||
setSuccess(null);
|
||||
@@ -1655,6 +1681,8 @@ function FingerprintSectionInline({
|
||||
});
|
||||
}
|
||||
setSuccess(t("common.buttons.saved"));
|
||||
// Close the dialog once the fingerprint is saved.
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
|
||||
@@ -1139,10 +1139,10 @@ export function SharedCamoufoxConfigForm({
|
||||
{limitedMode && (
|
||||
<>
|
||||
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
|
||||
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 left-0 w-6 bg-linear-to-r from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 right-0 w-6 bg-linear-to-l from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 top-0 h-6 bg-linear-to-b from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-[3]">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
|
||||
<ProBadge />
|
||||
@@ -1355,10 +1355,10 @@ export function SharedCamoufoxConfigForm({
|
||||
{limitedMode && (
|
||||
<>
|
||||
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
|
||||
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 left-0 w-6 bg-linear-to-r from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 right-0 w-6 bg-linear-to-l from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 top-0 h-6 bg-linear-to-b from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-[3]">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
|
||||
<ProBadge />
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import confetti from "canvas-confetti";
|
||||
import { motion } from "motion/react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Logo } from "@/components/icons/logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
const spring = { type: "spring", stiffness: 240, damping: 22 } as const;
|
||||
|
||||
// Celebratory close-out of the first-run onboarding: thanks the user and fires
|
||||
// confetti. Shown once the product tour is finished.
|
||||
export function ThankYouDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const fire = (options: confetti.Options) => {
|
||||
void confetti({ origin: { y: 0.7 }, ...options });
|
||||
};
|
||||
fire({ particleCount: 110, spread: 70, startVelocity: 48 });
|
||||
const t1 = setTimeout(
|
||||
() => fire({ particleCount: 70, spread: 100, decay: 0.92 }),
|
||||
200,
|
||||
);
|
||||
const t2 = setTimeout(
|
||||
() => fire({ particleCount: 50, spread: 120, scalar: 0.9 }),
|
||||
420,
|
||||
);
|
||||
return () => {
|
||||
clearTimeout(t1);
|
||||
clearTimeout(t2);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<div className="flex flex-col items-center gap-6 text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.6, rotate: -12 }}
|
||||
animate={{ opacity: 1, scale: 1, rotate: 0 }}
|
||||
transition={{ ...spring, delay: 0.05 }}
|
||||
className="text-foreground"
|
||||
>
|
||||
<Logo className="size-14" />
|
||||
</motion.div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<DialogTitle className="text-2xl font-semibold tracking-tight text-balance">
|
||||
{t("onboarding.thankYou.title")}
|
||||
</DialogTitle>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ ...spring, delay: 0.15 }}
|
||||
className="mx-auto max-w-[46ch] text-sm leading-6 text-pretty text-muted-foreground"
|
||||
>
|
||||
{t("onboarding.thankYou.body")}
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
<Button size="sm" onClick={onClose}>
|
||||
{t("onboarding.thankYou.cta")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -312,7 +312,7 @@ export const ColorPickerAlpha = ({
|
||||
'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==") left center',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent rounded-full to-black/50" />
|
||||
<div className="absolute inset-0 bg-linear-to-r from-transparent rounded-full to-black/50" />
|
||||
<Slider.Range className="absolute h-full bg-transparent rounded-full" />
|
||||
</Slider.Track>
|
||||
<Slider.Thumb className="block size-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
|
||||
@@ -111,26 +111,39 @@ function DialogOverlay({
|
||||
className={cn("fixed inset-0 z-9999 bg-background/50", className)}
|
||||
{...props}
|
||||
>
|
||||
{/* Keep the OS title-bar zone draggable while a modal is open — the
|
||||
overlay otherwise covers the native drag region. `data-window-drag-area`
|
||||
stops Radix from treating a drag here as an outside-click dismiss. */}
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
data-window-drag-area="true"
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 top-0 h-11"
|
||||
/>
|
||||
<WindowDragArea />
|
||||
</motion.div>
|
||||
</DialogPrimitive.Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogFlipDirection = "top" | "bottom" | "left" | "right";
|
||||
|
||||
type DialogContentProps = Omit<
|
||||
React.ComponentProps<typeof DialogPrimitive.Content>,
|
||||
"forceMount" | "asChild"
|
||||
> &
|
||||
HTMLMotionProps<"div"> & {
|
||||
from?: DialogFlipDirection;
|
||||
/**
|
||||
* Suppress the built-in top-right close X. Use when the dialog renders
|
||||
* its own header bar with a custom close control to avoid two X buttons
|
||||
* stacking near the corner.
|
||||
*/
|
||||
hideClose?: boolean;
|
||||
/**
|
||||
* When false, the user cannot dismiss the dialog — Escape and outside
|
||||
* clicks are ignored and the close X is hidden. Use for steps the user
|
||||
* must complete to progress (e.g. required onboarding, a blocking
|
||||
* download). The dialog can still be closed programmatically via `open`.
|
||||
*/
|
||||
dismissible?: boolean;
|
||||
};
|
||||
|
||||
function SubPageContent({
|
||||
@@ -176,7 +189,6 @@ function SubPageContent({
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
from = "top",
|
||||
onOpenAutoFocus,
|
||||
onCloseAutoFocus,
|
||||
onEscapeKeyDown,
|
||||
@@ -184,19 +196,11 @@ function DialogContent({
|
||||
onInteractOutside,
|
||||
transition,
|
||||
hideClose,
|
||||
dismissible = true,
|
||||
...props
|
||||
}: DialogContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const { subPage } = useDialog();
|
||||
const initialRotation =
|
||||
from === "bottom" || from === "left" ? "20deg" : "-20deg";
|
||||
const isVertical = from === "top" || from === "bottom";
|
||||
const rotateAxis = isVertical ? "rotateX" : "rotateY";
|
||||
const finalTransition = transition ?? {
|
||||
type: "spring",
|
||||
stiffness: 220,
|
||||
damping: 26,
|
||||
};
|
||||
|
||||
if (subPage) {
|
||||
return <SubPageContent>{children}</SubPageContent>;
|
||||
@@ -210,9 +214,16 @@ function DialogContent({
|
||||
forceMount
|
||||
onOpenAutoFocus={onOpenAutoFocus}
|
||||
onCloseAutoFocus={onCloseAutoFocus}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (!dismissible) event.preventDefault();
|
||||
onEscapeKeyDown?.(event);
|
||||
}}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onInteractOutside={(event) => {
|
||||
if (!dismissible) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest('[data-window-drag-area="true"]')) {
|
||||
event.preventDefault();
|
||||
@@ -223,22 +234,25 @@ function DialogContent({
|
||||
<motion.div
|
||||
key="dialog-content"
|
||||
data-slot="dialog-content"
|
||||
initial={{
|
||||
opacity: 0,
|
||||
filter: "blur(4px)",
|
||||
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
transform: `perspective(500px) ${rotateAxis}(0deg) scale(1)`,
|
||||
}}
|
||||
// Open/close motion modeled on transitions.dev's modal: a subtle
|
||||
// scale from 0.96 → 1 with opacity, eased with cubic-bezier(0.22, 1,
|
||||
// 0.36, 1). Open is 250ms; close is a quicker 150ms. The centering
|
||||
// translate stays in `style` so `scale` animates around the center
|
||||
// without fighting the transform-based positioning.
|
||||
style={{ transformOrigin: "center" }}
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
filter: "blur(4px)",
|
||||
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
|
||||
scale: 0.96,
|
||||
transition: transition ?? {
|
||||
duration: 0.15,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
},
|
||||
}}
|
||||
transition={finalTransition}
|
||||
transition={
|
||||
transition ?? { duration: 0.25, ease: [0.22, 1, 0.36, 1] }
|
||||
}
|
||||
className={cn(
|
||||
"bg-background fixed top-[50%] left-[50%] z-10000 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg",
|
||||
className,
|
||||
@@ -246,7 +260,7 @@ function DialogContent({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{!hideClose && (
|
||||
{!hideClose && dismissible && (
|
||||
<DialogPrimitive.Close className="cursor-pointer ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<RxCross2 />
|
||||
<span className="sr-only">{t("common.buttons.close")}</span>
|
||||
|
||||
@@ -15,12 +15,12 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
"--normal-bg": "var(--card)",
|
||||
"--normal-text": "var(--card-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
zIndex: 99999,
|
||||
zIndex: 10001,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
style: {
|
||||
zIndex: 99999,
|
||||
zIndex: 10001,
|
||||
pointerEvents: "auto",
|
||||
backdropFilter: "saturate(1.2)",
|
||||
},
|
||||
|
||||
@@ -1095,10 +1095,10 @@ export function WayfernConfigForm({
|
||||
{limitedMode && (
|
||||
<>
|
||||
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
|
||||
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 left-0 w-6 bg-linear-to-r from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 right-0 w-6 bg-linear-to-l from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 top-0 h-6 bg-linear-to-b from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-[3]">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
|
||||
<ProBadge />
|
||||
@@ -1318,10 +1318,10 @@ export function WayfernConfigForm({
|
||||
{limitedMode && (
|
||||
<>
|
||||
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
|
||||
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 left-0 w-6 bg-linear-to-r from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 right-0 w-6 bg-linear-to-l from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 top-0 h-6 bg-linear-to-b from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-[3]">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
|
||||
<ProBadge />
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LuArrowRight,
|
||||
LuBriefcase,
|
||||
LuCookie,
|
||||
LuFolders,
|
||||
LuGithub,
|
||||
LuGlobe,
|
||||
LuHeart,
|
||||
LuLoaderCircle,
|
||||
LuMic,
|
||||
LuNetwork,
|
||||
LuShieldCheck,
|
||||
LuTerminal,
|
||||
LuTriangleAlert,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
import { Logo } from "@/components/icons/logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useBrowserSetup } from "@/hooks/use-browser-setup";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { getBrowserDisplayName } from "@/lib/browser-utils";
|
||||
|
||||
type WelcomeStep = "intro" | "license" | "permissions" | "setup";
|
||||
|
||||
const panelTransition = {
|
||||
type: "spring",
|
||||
stiffness: 260,
|
||||
damping: 28,
|
||||
} as const;
|
||||
|
||||
const panelVariants = {
|
||||
enter: { opacity: 0, y: 12 },
|
||||
center: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -12 },
|
||||
};
|
||||
|
||||
// Concrete feature list shown on the intro step, rendered as an icon grid.
|
||||
const FEATURES = [
|
||||
{ key: "welcome.features.items.setDefault", Icon: LuGlobe },
|
||||
{ key: "welcome.features.items.proxy", Icon: LuNetwork },
|
||||
{ key: "welcome.features.items.vpn", Icon: LuShieldCheck },
|
||||
{ key: "welcome.features.items.profiles", Icon: LuUsers },
|
||||
{ key: "welcome.features.items.api", Icon: LuTerminal },
|
||||
{ key: "welcome.features.items.openSource", Icon: LuGithub },
|
||||
{ key: "welcome.features.items.groups", Icon: LuFolders },
|
||||
{ key: "welcome.features.items.cookies", Icon: LuCookie },
|
||||
] as const;
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!(bytes > 0)) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
const exponent = Math.min(
|
||||
units.length - 1,
|
||||
Math.floor(Math.log(bytes) / Math.log(1024)),
|
||||
);
|
||||
const value = bytes / 1024 ** exponent;
|
||||
const rounded = exponent === 0 ? value : Math.round(value * 10) / 10;
|
||||
return `${rounded} ${units[exponent]}`;
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const total = Math.max(0, Math.round(seconds));
|
||||
if (total < 60) return `${total}s`;
|
||||
const minutes = Math.floor(total / 60);
|
||||
const remainder = total % 60;
|
||||
return `${minutes}m ${String(remainder).padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
export function WelcomeDialog({
|
||||
isOpen,
|
||||
needsSetup,
|
||||
onComplete,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
/**
|
||||
* Whether this user still needs the browser-download + profile-creation flow.
|
||||
* False when they already have a profile — then the welcome and commercial-use
|
||||
* steps still show, but "continue" finishes onboarding instead of proceeding
|
||||
* to permissions/download.
|
||||
*/
|
||||
needsSetup: boolean;
|
||||
onComplete: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { requestPermission } = usePermissions();
|
||||
const [step, setStep] = useState<WelcomeStep>("intro");
|
||||
// Where the "skip" / "continue" affordances go: into the setup flow when a
|
||||
// browser/profile is still needed, otherwise straight to completion.
|
||||
const advanceToSetup = () => {
|
||||
if (needsSetup) setStep("setup");
|
||||
else onComplete();
|
||||
};
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
|
||||
// Track the required browser's download + extraction the whole time the
|
||||
// dialog is open, so progress is live by the time the user reaches setup.
|
||||
const setup = useBrowserSetup("wayfern", isOpen);
|
||||
const browserName = getBrowserDisplayName("wayfern");
|
||||
|
||||
const requestPermissions = useCallback(async () => {
|
||||
setRequesting(true);
|
||||
try {
|
||||
await requestPermission("microphone");
|
||||
await requestPermission("camera");
|
||||
} catch (err) {
|
||||
console.error("Permission request failed:", err);
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
setStep("setup");
|
||||
}
|
||||
}, [requestPermission]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={() => {}}>
|
||||
<DialogContent
|
||||
dismissible={false}
|
||||
className="overflow-hidden sm:max-w-xl"
|
||||
>
|
||||
<DialogTitle className="sr-only">{t("welcome.title")}</DialogTitle>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{step === "intro" && (
|
||||
<motion.div
|
||||
key="intro"
|
||||
variants={panelVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={panelTransition}
|
||||
className="flex flex-col gap-7"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ ...panelTransition, delay: 0.05 }}
|
||||
className="text-foreground"
|
||||
>
|
||||
<Logo className="size-12" />
|
||||
</motion.div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-2xl font-semibold tracking-tight text-balance">
|
||||
{t("welcome.title")}
|
||||
</h2>
|
||||
<p className="mx-auto max-w-[55ch] text-sm text-pretty text-muted-foreground">
|
||||
{t("welcome.tagline")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("welcome.features.title")}
|
||||
</p>
|
||||
<dl className="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
|
||||
{FEATURES.map(({ key, Icon }, i) => (
|
||||
<motion.div
|
||||
key={key}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
...panelTransition,
|
||||
delay: 0.12 + i * 0.04,
|
||||
}}
|
||||
className="flex items-center gap-2.5"
|
||||
>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<dt className="text-sm font-medium text-foreground">
|
||||
{t(key)}
|
||||
</dt>
|
||||
</motion.div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={advanceToSetup}
|
||||
>
|
||||
{t("welcome.skip")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => setStep("license")}
|
||||
>
|
||||
{t("welcome.next")}
|
||||
<LuArrowRight className="size-4 shrink-0" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === "license" && (
|
||||
<motion.div
|
||||
key="license"
|
||||
variants={panelVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={panelTransition}
|
||||
className="flex flex-col gap-7"
|
||||
>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<h2 className="text-2xl font-semibold tracking-tight text-balance">
|
||||
{t("welcome.license.title")}
|
||||
</h2>
|
||||
<p className="mx-auto max-w-[55ch] text-sm leading-6 text-pretty text-muted-foreground">
|
||||
{t("welcome.license.body")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="flex flex-col gap-3">
|
||||
<div className="flex items-start gap-3 rounded-lg border p-4">
|
||||
<LuHeart className="mt-0.5 size-4 shrink-0 text-success" />
|
||||
<div className="flex flex-col gap-0.5 text-left">
|
||||
<dt className="text-sm font-medium text-foreground">
|
||||
{t("welcome.license.personalTitle")}
|
||||
</dt>
|
||||
<dd className="text-sm text-pretty text-muted-foreground">
|
||||
{t("welcome.license.personalDesc")}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 rounded-lg border p-4">
|
||||
<LuBriefcase className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-0.5 text-left">
|
||||
<dt className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
{t("welcome.license.commercialTitle")}
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
{t("welcome.license.trialBadge")}
|
||||
</span>
|
||||
</dt>
|
||||
<dd className="text-sm text-pretty text-muted-foreground">
|
||||
{t("welcome.license.commercialDesc")}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={advanceToSetup}
|
||||
>
|
||||
{t("welcome.skip")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => {
|
||||
if (needsSetup) setStep("permissions");
|
||||
else onComplete();
|
||||
}}
|
||||
>
|
||||
{t("welcome.license.agree")}
|
||||
<LuArrowRight className="size-4 shrink-0" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === "permissions" && (
|
||||
<motion.div
|
||||
key="permissions"
|
||||
variants={panelVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={panelTransition}
|
||||
className="flex flex-col gap-7"
|
||||
>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<h2 className="flex items-center justify-center gap-2 text-2xl font-semibold tracking-tight text-balance">
|
||||
<LuMic className="size-5 shrink-0" />
|
||||
{t("welcome.permissions.title")}
|
||||
</h2>
|
||||
<p className="mx-auto max-w-[55ch] text-sm leading-6 text-pretty text-muted-foreground">
|
||||
{t("welcome.permissions.desc")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
disabled={requesting}
|
||||
onClick={advanceToSetup}
|
||||
>
|
||||
{t("welcome.permissions.skip")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
disabled={requesting}
|
||||
onClick={() => {
|
||||
void requestPermissions();
|
||||
}}
|
||||
>
|
||||
{requesting && (
|
||||
<LuLoaderCircle className="size-4 shrink-0 animate-spin" />
|
||||
)}
|
||||
{requesting
|
||||
? t("welcome.permissions.requesting")
|
||||
: t("welcome.permissions.grant")}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === "setup" && (
|
||||
<motion.div
|
||||
key="setup"
|
||||
variants={panelVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={panelTransition}
|
||||
className="flex flex-col items-center gap-6 text-center"
|
||||
>
|
||||
{setup.phase === "error" ? (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<h2 className="flex items-center justify-center gap-2 text-2xl font-semibold tracking-tight text-balance text-destructive">
|
||||
<LuTriangleAlert className="size-5 shrink-0" />
|
||||
{t("welcome.ready.errorTitle")}
|
||||
</h2>
|
||||
<p className="max-w-[55ch] text-sm leading-6 text-pretty text-muted-foreground">
|
||||
{setup.error?.stage === "downloading"
|
||||
? t("welcome.ready.errorDownload", {
|
||||
browser: browserName,
|
||||
})
|
||||
: setup.error?.stage === "extracting" ||
|
||||
setup.error?.stage === "verifying"
|
||||
? t("welcome.ready.errorExtraction", {
|
||||
browser: browserName,
|
||||
})
|
||||
: t("welcome.ready.errorGeneric", {
|
||||
browser: browserName,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* No escape hatch here: a browser must finish downloading
|
||||
before onboarding can complete, so the only action on
|
||||
failure is to retry. */}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setup.retry();
|
||||
}}
|
||||
>
|
||||
{t("welcome.ready.retry")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<h2 className="text-2xl font-semibold tracking-tight text-balance">
|
||||
{t("welcome.ready.title")}
|
||||
</h2>
|
||||
<p className="max-w-[55ch] text-sm leading-6 text-pretty text-muted-foreground">
|
||||
{setup.phase === "ready"
|
||||
? t("welcome.ready.descReady")
|
||||
: setup.phase === "extracting"
|
||||
? t("welcome.ready.descExtracting")
|
||||
: t("welcome.ready.descDownloading")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{setup.phase === "downloading" && (
|
||||
<div className="flex w-full max-w-xs flex-col gap-2">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-primary"
|
||||
initial={{ width: 0 }}
|
||||
animate={{
|
||||
width: `${Math.max(setup.downloadPercent, 4)}%`,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 120,
|
||||
damping: 24,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm tabular-nums text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<LuLoaderCircle className="size-4 shrink-0 animate-spin" />
|
||||
{t("welcome.ready.downloading")}
|
||||
</span>
|
||||
<span>{setup.downloadPercent}%</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-0.5 text-xs tabular-nums text-muted-foreground">
|
||||
<span>
|
||||
{setup.totalBytes != null
|
||||
? t("welcome.ready.stats", {
|
||||
downloaded: formatBytes(setup.downloadedBytes),
|
||||
total: formatBytes(setup.totalBytes),
|
||||
})
|
||||
: formatBytes(setup.downloadedBytes)}
|
||||
</span>
|
||||
{setup.speedBytesPerSec > 0 && (
|
||||
<span>
|
||||
{t("welcome.ready.speed", {
|
||||
speed: formatBytes(setup.speedBytesPerSec),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{setup.etaSeconds != null &&
|
||||
Number.isFinite(setup.etaSeconds) &&
|
||||
setup.etaSeconds > 0 && (
|
||||
<span>
|
||||
{t("welcome.ready.timeLeft", {
|
||||
time: formatDuration(setup.etaSeconds),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{setup.phase === "extracting" && (
|
||||
<div className="flex w-full max-w-xs flex-col gap-2">
|
||||
{setup.extractionOvertime ? (
|
||||
<div className="flex items-center justify-center gap-1.5 text-sm tabular-nums text-muted-foreground">
|
||||
<LuLoaderCircle className="size-4 shrink-0 animate-spin" />
|
||||
{t("welcome.ready.almostFinished")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-primary"
|
||||
initial={{ width: 0 }}
|
||||
animate={{
|
||||
width: `${Math.max(setup.extractionPercent, 4)}%`,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 120,
|
||||
damping: 24,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm tabular-nums text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<LuLoaderCircle className="size-4 shrink-0 animate-spin" />
|
||||
{t("welcome.ready.extracting")}
|
||||
</span>
|
||||
<span>{setup.extractionPercent}%</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{setup.phase === "ready" && (
|
||||
<Button size="sm" className="gap-1.5" onClick={onComplete}>
|
||||
<LuArrowRight className="size-4 shrink-0" />
|
||||
{t("welcome.ready.cta")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user