mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-18 00:47:19 +02:00
refactor: cleanup
This commit is contained in:
@@ -29,14 +29,6 @@ export interface ConsistencyResult {
|
||||
const GLOBAL_DISABLE_KEY = "consistency-warn-disabled";
|
||||
const perProfileKey = (id: string) => `consistency-warn-skip-${id}`;
|
||||
|
||||
export function isConsistencyWarningEnabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(GLOBAL_DISABLE_KEY) !== "1";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function isConsistencyWarningSuppressed(profileId: string): boolean {
|
||||
try {
|
||||
return (
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
export const ZenBrowser = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={24}
|
||||
height={24}
|
||||
role="graphics-symbol img"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<title>Zen Browser</title>
|
||||
<path
|
||||
d="M12 8.15c-2.12 0-3.85 1.72-3.85 3.85s1.72 3.85 3.85 3.85 3.85-1.72 3.85-3.85S14.13 8.15 12 8.15m0 6.92c-1.7 0-3.08-1.38-3.08-3.08S10.3 8.91 12 8.91s3.08 1.38 3.08 3.08-1.38 3.08-3.08 3.08"
|
||||
className="b"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.33c-3.68 0-6.67 2.98-6.67 6.67s2.98 6.67 6.67 6.67 6.67-2.98 6.67-6.67S15.69 5.33 12 5.33m0 12.05c-2.97 0-5.38-2.41-5.38-5.38S9.03 6.62 12 6.62s5.38 2.41 5.38 5.38-2.41 5.38-5.38 5.38"
|
||||
className="b"
|
||||
/>
|
||||
<path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18.2c-4.53 0-8.21-3.67-8.21-8.2S7.47 3.79 12 3.79s8.21 3.67 8.21 8.21-3.67 8.2-8.21 8.2"
|
||||
className="b"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -213,8 +213,7 @@ export function IntegrationsDialog({
|
||||
} catch (e) {
|
||||
console.error("Failed to toggle API:", e);
|
||||
showErrorToast(t("integrations.apiToggleFailed"), {
|
||||
description:
|
||||
e instanceof Error ? e.message : t("integrations.apiUnknownError"),
|
||||
description: translateBackendError(t, e),
|
||||
});
|
||||
} finally {
|
||||
setIsApiStarting(false);
|
||||
@@ -245,8 +244,7 @@ export function IntegrationsDialog({
|
||||
} catch (e) {
|
||||
console.error("Failed to toggle MCP server:", e);
|
||||
showErrorToast(t("integrations.mcpToggleFailed"), {
|
||||
description:
|
||||
e instanceof Error ? e.message : t("integrations.apiUnknownError"),
|
||||
description: translateBackendError(t, e),
|
||||
});
|
||||
} finally {
|
||||
setIsMcpStarting(false);
|
||||
@@ -452,10 +450,7 @@ export function IntegrationsDialog({
|
||||
showErrorToast(
|
||||
t("integrations.apiStartFailed"),
|
||||
{
|
||||
description:
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: t("integrations.apiUnknownError"),
|
||||
description: translateBackendError(t, e),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { LocationItem } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface LocationProxyDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function LoadingSpinner() {
|
||||
return <Loader2 className="size-4 animate-spin text-muted-foreground" />;
|
||||
}
|
||||
|
||||
export function LocationProxyDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: LocationProxyDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [countries, setCountries] = useState<LocationItem[]>([]);
|
||||
const [regions, setRegions] = useState<LocationItem[]>([]);
|
||||
const [cities, setCities] = useState<LocationItem[]>([]);
|
||||
const [isps, setIsps] = useState<LocationItem[]>([]);
|
||||
|
||||
const [selectedCountry, setSelectedCountry] = useState("");
|
||||
const [selectedRegion, setSelectedRegion] = useState("");
|
||||
const [selectedCity, setSelectedCity] = useState("");
|
||||
const [selectedIsp, setSelectedIsp] = useState("");
|
||||
const [proxyName, setProxyName] = useState("");
|
||||
|
||||
const [isLoadingCountries, setIsLoadingCountries] = useState(false);
|
||||
const [isLoadingRegions, setIsLoadingRegions] = useState(false);
|
||||
const [isLoadingCities, setIsLoadingCities] = useState(false);
|
||||
const [isLoadingIsps, setIsLoadingIsps] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelectedCountry("");
|
||||
setSelectedRegion("");
|
||||
setSelectedCity("");
|
||||
setSelectedIsp("");
|
||||
setProxyName("");
|
||||
setRegions([]);
|
||||
setCities([]);
|
||||
setIsps([]);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
// Fetch countries on mount
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setIsLoadingCountries(true);
|
||||
void invoke<LocationItem[]>("cloud_get_countries")
|
||||
.then((data) => {
|
||||
setCountries(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to fetch countries:", err);
|
||||
toast.error(t("locationProxy.loadFailed"));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingCountries(false);
|
||||
});
|
||||
}, [isOpen, t]);
|
||||
|
||||
// Fetch regions when country changes
|
||||
useEffect(() => {
|
||||
if (!selectedCountry) {
|
||||
setRegions([]);
|
||||
return;
|
||||
}
|
||||
setIsLoadingRegions(true);
|
||||
setSelectedRegion("");
|
||||
setSelectedCity("");
|
||||
setSelectedIsp("");
|
||||
setCities([]);
|
||||
setIsps([]);
|
||||
void invoke<LocationItem[]>("cloud_get_regions", {
|
||||
country: selectedCountry,
|
||||
})
|
||||
.then((data) => {
|
||||
setRegions(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to fetch regions:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingRegions(false);
|
||||
});
|
||||
}, [selectedCountry]);
|
||||
|
||||
// Fetch cities when country or region changes (cities can be loaded without region)
|
||||
useEffect(() => {
|
||||
if (!selectedCountry) {
|
||||
setCities([]);
|
||||
return;
|
||||
}
|
||||
setIsLoadingCities(true);
|
||||
setSelectedCity("");
|
||||
const args: { country: string; region?: string } = {
|
||||
country: selectedCountry,
|
||||
};
|
||||
if (selectedRegion) {
|
||||
args.region = selectedRegion;
|
||||
}
|
||||
void invoke<LocationItem[]>("cloud_get_cities", args)
|
||||
.then((data) => {
|
||||
setCities(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to fetch cities:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingCities(false);
|
||||
});
|
||||
}, [selectedCountry, selectedRegion]);
|
||||
|
||||
// Fetch ISPs when country/region/city changes
|
||||
useEffect(() => {
|
||||
if (!selectedCountry) {
|
||||
setIsps([]);
|
||||
return;
|
||||
}
|
||||
setIsLoadingIsps(true);
|
||||
setSelectedIsp("");
|
||||
const args: { country: string; region?: string; city?: string } = {
|
||||
country: selectedCountry,
|
||||
};
|
||||
if (selectedRegion) args.region = selectedRegion;
|
||||
if (selectedCity) args.city = selectedCity;
|
||||
void invoke<LocationItem[]>("cloud_get_isps", args)
|
||||
.then((data) => {
|
||||
setIsps(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to fetch ISPs:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingIsps(false);
|
||||
});
|
||||
}, [selectedCountry, selectedRegion, selectedCity]);
|
||||
|
||||
// Auto-generate name from selections
|
||||
useEffect(() => {
|
||||
const parts: string[] = [];
|
||||
const countryItem = countries.find((c) => c.code === selectedCountry);
|
||||
if (countryItem) parts.push(countryItem.name);
|
||||
const regionItem = regions.find((s) => s.code === selectedRegion);
|
||||
if (regionItem) parts.push(regionItem.name);
|
||||
const cityItem = cities.find((c) => c.code === selectedCity);
|
||||
if (cityItem) parts.push(cityItem.name);
|
||||
const ispItem = isps.find((i) => i.code === selectedIsp);
|
||||
if (ispItem) parts.push(ispItem.name);
|
||||
if (parts.length > 0) {
|
||||
setProxyName(parts.join(" - "));
|
||||
}
|
||||
}, [
|
||||
selectedCountry,
|
||||
selectedRegion,
|
||||
selectedCity,
|
||||
selectedIsp,
|
||||
countries,
|
||||
regions,
|
||||
cities,
|
||||
isps,
|
||||
]);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!selectedCountry || !proxyName.trim()) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await invoke("create_cloud_location_proxy", {
|
||||
name: proxyName.trim(),
|
||||
country: selectedCountry,
|
||||
region: selectedRegion || null,
|
||||
city: selectedCity || null,
|
||||
isp: selectedIsp || null,
|
||||
});
|
||||
toast.success(t("locationProxy.createSuccess"));
|
||||
await emit("stored-proxies-changed");
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to create location proxy:", error);
|
||||
toast.error(
|
||||
typeof error === "string" ? error : t("locationProxy.createFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [
|
||||
selectedCountry,
|
||||
selectedRegion,
|
||||
selectedCity,
|
||||
selectedIsp,
|
||||
proxyName,
|
||||
handleClose,
|
||||
t,
|
||||
]);
|
||||
|
||||
const countryOptions = countries.map((c) => ({
|
||||
value: c.code,
|
||||
label: c.name,
|
||||
}));
|
||||
const regionOptions = regions.map((s) => ({ value: s.code, label: s.name }));
|
||||
const cityOptions = cities.map((c) => ({ value: c.code, label: c.name }));
|
||||
const ispOptions = isps.map((i) => ({ value: i.code, label: i.name }));
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("locationProxy.titleCreate")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("locationProxy.descriptionCreate")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[calc(100vh-16rem)] min-h-0 space-y-4 overflow-y-auto pr-1">
|
||||
{/* Country - always visible */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
{t("locationProxy.countryLabel")}
|
||||
{isLoadingCountries && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
options={countryOptions}
|
||||
value={selectedCountry}
|
||||
onValueChange={setSelectedCountry}
|
||||
placeholder={
|
||||
isLoadingCountries
|
||||
? t("locationProxy.loadingCountries")
|
||||
: t("locationProxy.selectCountryPh")
|
||||
}
|
||||
searchPlaceholder={t("locationProxy.searchCountries")}
|
||||
disabled={isLoadingCountries}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Region - always visible, disabled until country is selected */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
{t("locationProxy.regionLabel")}
|
||||
{isLoadingRegions && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
options={regionOptions}
|
||||
value={selectedRegion}
|
||||
onValueChange={setSelectedRegion}
|
||||
placeholder={
|
||||
!selectedCountry
|
||||
? t("locationProxy.selectCountryFirst")
|
||||
: isLoadingRegions
|
||||
? t("locationProxy.loadingRegions")
|
||||
: regionOptions.length === 0
|
||||
? t("locationProxy.noRegions")
|
||||
: t("locationProxy.selectRegion")
|
||||
}
|
||||
searchPlaceholder={t("locationProxy.searchRegions")}
|
||||
disabled={!selectedCountry || isLoadingRegions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* City - always visible, disabled until country is selected */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
{t("locationProxy.cityLabel")}
|
||||
{isLoadingCities && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
options={cityOptions}
|
||||
value={selectedCity}
|
||||
onValueChange={setSelectedCity}
|
||||
placeholder={
|
||||
!selectedCountry
|
||||
? t("locationProxy.selectCountryFirst")
|
||||
: isLoadingCities
|
||||
? t("locationProxy.loadingCities")
|
||||
: cityOptions.length === 0
|
||||
? t("locationProxy.noCities")
|
||||
: t("locationProxy.selectCity")
|
||||
}
|
||||
searchPlaceholder={t("locationProxy.searchCities")}
|
||||
disabled={!selectedCountry || isLoadingCities}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ISP - always visible, disabled until country is selected */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
{t("locationProxy.ispLabel")}
|
||||
{isLoadingIsps && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
options={ispOptions}
|
||||
value={selectedIsp}
|
||||
onValueChange={setSelectedIsp}
|
||||
placeholder={
|
||||
!selectedCountry
|
||||
? t("locationProxy.selectCountryFirst")
|
||||
: isLoadingIsps
|
||||
? t("locationProxy.loadingIsps")
|
||||
: ispOptions.length === 0
|
||||
? t("locationProxy.noIsps")
|
||||
: t("locationProxy.selectIsp")
|
||||
}
|
||||
searchPlaceholder={t("locationProxy.searchIsps")}
|
||||
disabled={!selectedCountry || isLoadingIsps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("locationProxy.nameLabel")}</Label>
|
||||
<Input
|
||||
value={proxyName}
|
||||
onChange={(e) => {
|
||||
setProxyName(e.target.value);
|
||||
}}
|
||||
placeholder={t("locationProxy.namePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
<RippleButton
|
||||
onClick={handleCreate}
|
||||
disabled={!selectedCountry || !proxyName.trim() || isCreating}
|
||||
>
|
||||
{isCreating
|
||||
? t("locationProxy.creatingButton")
|
||||
: t("locationProxy.createButton")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
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";
|
||||
import {
|
||||
ONBOARDING_TOUR_CLOSED_EVENT,
|
||||
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).
|
||||
// Custom Onborda card, themed with the app's CSS variables. Closing always
|
||||
// persists completion; finishing the last step also asks the page to celebrate.
|
||||
export function OnboardingCard({
|
||||
step,
|
||||
currentStep,
|
||||
@@ -19,6 +22,7 @@ export function OnboardingCard({
|
||||
}: CardComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const { closeOnborda } = useOnborda();
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
const isFirst = currentStep === 0;
|
||||
const isLast = currentStep === totalSteps - 1;
|
||||
@@ -26,31 +30,44 @@ export function OnboardingCard({
|
||||
// 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"]';
|
||||
const closeTour = () => {
|
||||
closeOnborda();
|
||||
window.dispatchEvent(new Event(ONBOARDING_TOUR_CLOSED_EVENT));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-80 max-w-[90vw] rounded-lg border bg-popover p-4 text-popover-foreground shadow-lg">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={
|
||||
reduceMotion
|
||||
? { duration: 0.15 }
|
||||
: { type: "spring", stiffness: 300, damping: 30 }
|
||||
}
|
||||
className="relative flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-4 rounded-lg border bg-popover p-4 text-popover-foreground shadow-lg"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-sm/tight font-semibold">{step.title}</h3>
|
||||
<h3 className="text-base font-semibold text-balance sm:text-sm">
|
||||
{step.title}
|
||||
</h3>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground tabular-nums">
|
||||
{currentStep + 1}/{totalSteps}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs/relaxed text-muted-foreground">
|
||||
<div className="text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
|
||||
{step.content}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{isLast ? (
|
||||
<span />
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
closeOnborda();
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={closeTour}
|
||||
>
|
||||
{t("onboarding.buttons.skip")}
|
||||
</Button>
|
||||
@@ -61,7 +78,6 @@ export function OnboardingCard({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
onClick={() => {
|
||||
prevStep();
|
||||
}}
|
||||
@@ -72,9 +88,8 @@ export function OnboardingCard({
|
||||
{isLast ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-3 text-xs"
|
||||
onClick={() => {
|
||||
closeOnborda();
|
||||
closeTour();
|
||||
window.dispatchEvent(new Event(ONBOARDING_TOUR_FINISHED_EVENT));
|
||||
}}
|
||||
>
|
||||
@@ -83,7 +98,6 @@ export function OnboardingCard({
|
||||
) : requiresAction ? null : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-3 text-xs"
|
||||
onClick={() => {
|
||||
nextStep();
|
||||
}}
|
||||
@@ -95,6 +109,6 @@ export function OnboardingCard({
|
||||
</div>
|
||||
|
||||
<span className="text-popover">{arrow}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,8 @@ export function OnboardingProvider({
|
||||
cardComponent={OnboardingCard}
|
||||
interact
|
||||
shadowRgb="0,0,0"
|
||||
shadowOpacity="0.6"
|
||||
shadowOpacity="0.55"
|
||||
cardTransition={{ type: "spring", bounce: 0, duration: 0.35 }}
|
||||
>
|
||||
{children}
|
||||
</Onborda>
|
||||
|
||||
@@ -40,24 +40,20 @@ export function PermissionDialog({
|
||||
const { t } = useTranslation();
|
||||
const [isRequesting, setIsRequesting] = useState(false);
|
||||
const [isWaitingForGrant, setIsWaitingForGrant] = useState(false);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
const {
|
||||
requestPermission,
|
||||
isMicrophoneAccessGranted,
|
||||
isCameraAccessGranted,
|
||||
} = usePermissions();
|
||||
isInitialized,
|
||||
requiresSystemPermissions,
|
||||
} = usePermissions(isOpen);
|
||||
|
||||
// Check if we're on macOS and close dialog if not
|
||||
// This gate only exists for macOS TCC permissions.
|
||||
useEffect(() => {
|
||||
const userAgent = navigator.userAgent;
|
||||
const isMac = userAgent.includes("Mac");
|
||||
setIsMacOS(isMac);
|
||||
|
||||
// If not macOS, close the dialog as permissions aren't needed
|
||||
if (!isMac) {
|
||||
if (isOpen && isInitialized && !requiresSystemPermissions) {
|
||||
onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
}, [isInitialized, isOpen, onClose, requiresSystemPermissions]);
|
||||
|
||||
// Get current permission status
|
||||
const isCurrentPermissionGranted =
|
||||
@@ -158,7 +154,9 @@ export function PermissionDialog({
|
||||
const handleRequestPermission = async () => {
|
||||
setIsRequesting(true);
|
||||
try {
|
||||
await requestPermission(permissionType);
|
||||
const granted = await requestPermission(permissionType);
|
||||
if (granted) return;
|
||||
|
||||
// The macOS permission poll runs every 5 s, so the new state can take
|
||||
// a moment to surface. Keep the grant button in its busy state for
|
||||
// that window so the user has clear feedback, and notify them if the
|
||||
@@ -187,7 +185,7 @@ export function PermissionDialog({
|
||||
};
|
||||
|
||||
// Don't render if not macOS
|
||||
if (!isMacOS) {
|
||||
if (!requiresSystemPermissions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -137,9 +137,7 @@ export function SettingsDialog({
|
||||
const [isLoadingPermissions, setIsLoadingPermissions] = useState(false);
|
||||
const [requestingPermission, setRequestingPermission] =
|
||||
useState<PermissionType | null>(null);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
const [dnsBlocklistDialogOpen, setDnsBlocklistDialogOpen] = useState(false);
|
||||
const [isLinux, setIsLinux] = useState(false);
|
||||
const [hasE2ePassword, setHasE2ePassword] = useState(false);
|
||||
const [e2ePassword, setE2ePassword] = useState("");
|
||||
const [e2ePasswordConfirm, setE2ePasswordConfirm] = useState("");
|
||||
@@ -168,7 +166,10 @@ export function SettingsDialog({
|
||||
requestPermission,
|
||||
isMicrophoneAccessGranted,
|
||||
isCameraAccessGranted,
|
||||
} = usePermissions();
|
||||
currentOS,
|
||||
} = usePermissions(isOpen);
|
||||
const isMacOS = currentOS === "macos";
|
||||
const isLinux = currentOS === "linux";
|
||||
const { trialStatus } = useCommercialTrial();
|
||||
const { user: cloudUser } = useCloudAuth();
|
||||
// Encryption is available to everyone except team members who aren't owners
|
||||
@@ -630,14 +631,7 @@ export function SettingsDialog({
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
// Check if we're on macOS
|
||||
const userAgent = navigator.userAgent;
|
||||
const isMac = userAgent.includes("Mac");
|
||||
setIsMacOS(isMac);
|
||||
const isLin = !userAgent.includes("Mac") && !userAgent.includes("Win");
|
||||
setIsLinux(isLin);
|
||||
|
||||
if (isMac) {
|
||||
if (isMacOS) {
|
||||
loadPermissions();
|
||||
}
|
||||
|
||||
@@ -653,7 +647,13 @@ export function SettingsDialog({
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}
|
||||
}, [isOpen, loadPermissions, checkDefaultBrowserStatus, loadSettings]);
|
||||
}, [
|
||||
isOpen,
|
||||
isMacOS,
|
||||
loadPermissions,
|
||||
checkDefaultBrowserStatus,
|
||||
loadSettings,
|
||||
]);
|
||||
|
||||
// Initialize language selection when dialog opens or language loads
|
||||
useEffect(() => {
|
||||
@@ -1023,7 +1023,7 @@ export function SettingsDialog({
|
||||
});
|
||||
}}
|
||||
>
|
||||
Grant
|
||||
{t("common.buttons.grant")}
|
||||
</LoadingButton>
|
||||
)}
|
||||
</div>
|
||||
@@ -1032,10 +1032,8 @@ export function SettingsDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
These permissions allow browsers launched from Donut Browser
|
||||
to access system resources. Each website will still ask for
|
||||
your permission individually.
|
||||
<p className="text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
|
||||
{t("settings.permissions.description")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1045,7 +1043,7 @@ export function SettingsDialog({
|
||||
<Label className="text-base font-medium">
|
||||
{t("settings.integrations.title")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
|
||||
{t("settings.integrations.description")}
|
||||
</p>
|
||||
<RippleButton
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import confetti from "canvas-confetti";
|
||||
import { motion } from "motion/react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Logo } from "@/components/icons/logo";
|
||||
@@ -20,9 +20,10 @@ export function ThankYouDialog({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (!isOpen || reduceMotion) return;
|
||||
const fire = (options: confetti.Options) => {
|
||||
void confetti({ origin: { y: 0.7 }, ...options });
|
||||
};
|
||||
@@ -39,7 +40,7 @@ export function ThankYouDialog({
|
||||
clearTimeout(t1);
|
||||
clearTimeout(t2);
|
||||
};
|
||||
}, [isOpen]);
|
||||
}, [isOpen, reduceMotion]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -48,12 +49,15 @@ export function ThankYouDialog({
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="p-4 sm:max-w-md sm:p-6">
|
||||
<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 }}
|
||||
transition={{
|
||||
...(reduceMotion ? { duration: 0.15 } : spring),
|
||||
delay: reduceMotion ? 0 : 0.05,
|
||||
}}
|
||||
className="text-foreground"
|
||||
>
|
||||
<Logo className="size-14" />
|
||||
@@ -66,8 +70,11 @@ export function ThankYouDialog({
|
||||
<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/6 text-pretty text-muted-foreground"
|
||||
transition={{
|
||||
...(reduceMotion ? { duration: 0.15 } : spring),
|
||||
delay: reduceMotion ? 0 : 0.15,
|
||||
}}
|
||||
className="mx-auto max-w-[46ch] text-base/7 text-pretty text-muted-foreground sm:text-sm/6"
|
||||
>
|
||||
{t("onboarding.thankYou.body")}
|
||||
</motion.p>
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
};
|
||||
@@ -1,386 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
import type {
|
||||
Props as DefaultLegendContentProps,
|
||||
LegendPayload,
|
||||
} from "recharts/types/component/DefaultLegendContent";
|
||||
import type {
|
||||
NameType,
|
||||
ValueType,
|
||||
} from "recharts/types/component/DefaultTooltipContent";
|
||||
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"];
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video max-h-[min(45vh,20rem)] w-full justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={1}
|
||||
minHeight={1}
|
||||
>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
});
|
||||
ChartContainer.displayName = "Chart";
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color,
|
||||
);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: Safe usage for CSS variables from chart config
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
TooltipContentProps<ValueType, NameType> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
labelClassName?: string;
|
||||
color?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload?.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={String(item.dataKey ?? index)}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:size-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
||||
{
|
||||
"size-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
ChartTooltipContent.displayName = "ChartTooltip";
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<DefaultLegendContentProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
|
||||
ref,
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item: LegendPayload) => item.type !== "none")
|
||||
.map((item: LegendPayload) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:size-3 [&>svg]:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="size-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
ChartLegendContent.displayName = "ChartLegend";
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
};
|
||||
@@ -1,115 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ComboboxOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ComboboxProps {
|
||||
options: ComboboxOption[];
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Combobox({
|
||||
options,
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
className,
|
||||
disabled,
|
||||
}: ComboboxProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const listboxId = React.useId();
|
||||
|
||||
const resolvedPlaceholder = placeholder ?? t("common.buttons.select");
|
||||
const resolvedSearchPlaceholder =
|
||||
searchPlaceholder ?? t("common.buttons.search");
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
disabled={disabled}
|
||||
className={cn("w-full justify-between", className)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{value
|
||||
? options.find((option) => option.value === value)?.label
|
||||
: resolvedPlaceholder}
|
||||
</span>
|
||||
<LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
id={listboxId}
|
||||
className="w-(--radix-popover-trigger-width) p-0"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={resolvedSearchPlaceholder} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("common.noResults")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={(currentValue) => {
|
||||
onValueChange(currentValue === value ? "" : currentValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
value === option.value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate">{option.label}</span>
|
||||
{option.description && (
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -263,7 +263,7 @@ function DialogContent({
|
||||
// w-[calc(100%-2rem)] (not w-full + max-w) keeps the 1rem window
|
||||
// gutter even when callers override max-w-*: tailwind-merge drops
|
||||
// a base max-w in favor of the caller's, but leaves width alone.
|
||||
"surface-material fixed top-[50%] left-[50%] z-10000 grid max-h-[calc(100vh-3rem)] w-[calc(100%-2rem)] max-w-lg -translate-[50%] gap-4 overflow-y-auto rounded-lg border p-6 shadow-lg",
|
||||
"surface-material fixed top-[50%] left-[50%] z-10000 grid max-h-[calc(100dvh-3rem)] w-[calc(100%-2rem)] max-w-lg -translate-[50%] gap-4 overflow-y-auto rounded-lg border p-6 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LuArrowRight,
|
||||
LuBriefcase,
|
||||
LuCamera,
|
||||
LuCookie,
|
||||
LuFolders,
|
||||
LuGithub,
|
||||
@@ -25,10 +26,11 @@ 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";
|
||||
import { getCurrentOS } from "@/lib/platform";
|
||||
|
||||
type WelcomeStep = "intro" | "license" | "permissions" | "setup";
|
||||
|
||||
const panelTransition = {
|
||||
const panelSpring = {
|
||||
type: "spring",
|
||||
stiffness: 260,
|
||||
damping: 28,
|
||||
@@ -52,24 +54,71 @@ const FEATURES = [
|
||||
{ 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 BYTE_UNITS = ["byte", "kilobyte", "megabyte", "gigabyte"] as const;
|
||||
|
||||
function formatBytes(bytes: number, locale: string): string {
|
||||
const exponent = Math.min(
|
||||
units.length - 1,
|
||||
Math.floor(Math.log(bytes) / Math.log(1024)),
|
||||
BYTE_UNITS.length - 1,
|
||||
bytes > 0 ? Math.floor(Math.log(bytes) / Math.log(1024)) : 0,
|
||||
);
|
||||
const value = bytes / 1024 ** exponent;
|
||||
const rounded = exponent === 0 ? value : Math.round(value * 10) / 10;
|
||||
return `${rounded} ${units[exponent]}`;
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: "unit",
|
||||
unit: BYTE_UNITS[exponent],
|
||||
unitDisplay: "short",
|
||||
maximumFractionDigits: exponent === 0 ? 0 : 1,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
function formatDuration(seconds: number, locale: string): string {
|
||||
const total = Math.max(0, Math.round(seconds));
|
||||
if (total < 60) return `${total}s`;
|
||||
const formatUnit = (
|
||||
value: number,
|
||||
unit: "minute" | "second",
|
||||
minimumIntegerDigits = 1,
|
||||
) =>
|
||||
new Intl.NumberFormat(locale, {
|
||||
style: "unit",
|
||||
unit,
|
||||
unitDisplay: "narrow",
|
||||
minimumIntegerDigits,
|
||||
}).format(value);
|
||||
|
||||
if (total < 60) return formatUnit(total, "second");
|
||||
const minutes = Math.floor(total / 60);
|
||||
const remainder = total % 60;
|
||||
return `${minutes}m ${String(remainder).padStart(2, "0")}s`;
|
||||
return `${formatUnit(minutes, "minute")} ${formatUnit(
|
||||
remainder,
|
||||
"second",
|
||||
2,
|
||||
)}`;
|
||||
}
|
||||
|
||||
function SetupProgress({ value, label }: { value: number; label: string }) {
|
||||
const normalizedValue = Math.min(100, Math.max(0, value));
|
||||
const determined = normalizedValue > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={label}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={determined ? normalizedValue : undefined}
|
||||
className="h-1.5 w-full overflow-hidden rounded-full bg-muted"
|
||||
>
|
||||
{determined ? (
|
||||
<motion.div
|
||||
className="h-full origin-left rounded-full bg-primary"
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: normalizedValue / 100 }}
|
||||
transition={{ type: "spring", stiffness: 120, damping: 24 }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-1/3 rounded-full bg-primary motion-safe:animate-progress-indeterminate motion-reduce:translate-x-0" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WelcomeDialog({
|
||||
@@ -87,9 +136,30 @@ export function WelcomeDialog({
|
||||
needsSetup: boolean;
|
||||
onComplete: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { requestPermission } = usePermissions();
|
||||
const { t, i18n } = useTranslation();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const {
|
||||
requestPermission,
|
||||
isMicrophoneAccessGranted,
|
||||
isCameraAccessGranted,
|
||||
isInitialized,
|
||||
requiresSystemPermissions,
|
||||
} = usePermissions(isOpen);
|
||||
const [step, setStep] = useState<WelcomeStep>("intro");
|
||||
const permissionsGranted = isMicrophoneAccessGranted && isCameraAccessGranted;
|
||||
const showPermissionsStep =
|
||||
requiresSystemPermissions &&
|
||||
(step === "permissions" || !isInitialized || !permissionsGranted);
|
||||
const visibleSteps: WelcomeStep[] = [
|
||||
"intro",
|
||||
"license",
|
||||
...(showPermissionsStep ? (["permissions"] as const) : []),
|
||||
...(needsSetup ? (["setup"] as const) : []),
|
||||
];
|
||||
const currentStepIndex = Math.max(0, visibleSteps.indexOf(step));
|
||||
const panelTransition = reduceMotion
|
||||
? ({ duration: 0.15 } as const)
|
||||
: panelSpring;
|
||||
// Where the "skip" / "continue" affordances go: into the setup flow when a
|
||||
// browser/profile is still needed, otherwise straight to completion.
|
||||
const advanceToSetup = () => {
|
||||
@@ -106,24 +176,46 @@ export function WelcomeDialog({
|
||||
const requestPermissions = useCallback(async () => {
|
||||
setRequesting(true);
|
||||
try {
|
||||
await requestPermission("microphone");
|
||||
await requestPermission("camera");
|
||||
if (!isMicrophoneAccessGranted) {
|
||||
await requestPermission("microphone");
|
||||
}
|
||||
if (!isCameraAccessGranted) {
|
||||
await requestPermission("camera");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Permission request failed:", err);
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
setStep("setup");
|
||||
}
|
||||
}, [requestPermission]);
|
||||
}, [isCameraAccessGranted, isMicrophoneAccessGranted, requestPermission]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={() => {}}>
|
||||
<DialogContent
|
||||
dismissible={false}
|
||||
className="overflow-x-hidden sm:max-w-xl"
|
||||
className="overflow-x-hidden p-4 sm:max-w-xl sm:p-6"
|
||||
>
|
||||
<DialogTitle className="sr-only">{t("welcome.title")}</DialogTitle>
|
||||
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={t("welcome.title")}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={visibleSteps.length}
|
||||
aria-valuenow={currentStepIndex + 1}
|
||||
className="mx-auto h-1 w-24 overflow-hidden rounded-full bg-muted"
|
||||
>
|
||||
<motion.div
|
||||
className="h-full origin-left rounded-full bg-primary"
|
||||
initial={false}
|
||||
animate={{
|
||||
scaleX: (currentStepIndex + 1) / visibleSteps.length,
|
||||
}}
|
||||
transition={panelTransition}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{step === "intro" && (
|
||||
<motion.div
|
||||
@@ -139,7 +231,10 @@ export function WelcomeDialog({
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ ...panelTransition, delay: 0.05 }}
|
||||
transition={{
|
||||
...panelTransition,
|
||||
delay: reduceMotion ? 0 : 0.05,
|
||||
}}
|
||||
className="text-foreground"
|
||||
>
|
||||
<Logo className="size-12" />
|
||||
@@ -148,14 +243,14 @@ export function WelcomeDialog({
|
||||
<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">
|
||||
<p className="mx-auto max-w-[55ch] text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
|
||||
{t("welcome.tagline")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
<p className="text-base/7 font-medium text-muted-foreground sm:text-sm/6">
|
||||
{t("welcome.features.title")}
|
||||
</p>
|
||||
<dl className="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
|
||||
@@ -166,12 +261,12 @@ export function WelcomeDialog({
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
...panelTransition,
|
||||
delay: 0.12 + i * 0.04,
|
||||
delay: reduceMotion ? 0 : 0.12 + i * 0.04,
|
||||
}}
|
||||
className="flex items-center gap-2.5"
|
||||
className="flex min-w-0 items-center gap-2.5"
|
||||
>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<dt className="text-sm font-medium text-foreground">
|
||||
<dt className="text-base/7 font-medium text-foreground sm:text-sm/6">
|
||||
{t(key)}
|
||||
</dt>
|
||||
</motion.div>
|
||||
@@ -179,7 +274,7 @@ export function WelcomeDialog({
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -214,7 +309,7 @@ export function WelcomeDialog({
|
||||
<h2 className="text-2xl font-semibold tracking-tight text-balance">
|
||||
{t("welcome.license.title")}
|
||||
</h2>
|
||||
<p className="mx-auto max-w-[55ch] text-sm/6 text-pretty text-muted-foreground">
|
||||
<p className="mx-auto max-w-[55ch] text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
|
||||
{t("welcome.license.body")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -223,10 +318,10 @@ export function WelcomeDialog({
|
||||
<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">
|
||||
<dt className="text-base/7 font-medium text-foreground sm:text-sm/6">
|
||||
{t("welcome.license.personalTitle")}
|
||||
</dt>
|
||||
<dd className="text-sm text-pretty text-muted-foreground">
|
||||
<dd className="text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
|
||||
{t("welcome.license.personalDesc")}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -234,20 +329,20 @@ export function WelcomeDialog({
|
||||
<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">
|
||||
<dt className="flex flex-wrap items-center gap-2 text-base/7 font-medium text-foreground sm:text-sm/6">
|
||||
{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">
|
||||
<dd className="text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
|
||||
{t("welcome.license.commercialDesc")}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -260,8 +355,16 @@ export function WelcomeDialog({
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => {
|
||||
if (needsSetup) setStep("permissions");
|
||||
else onComplete();
|
||||
if (!needsSetup) {
|
||||
onComplete();
|
||||
} else if (
|
||||
getCurrentOS() === "macos" &&
|
||||
!(isInitialized && permissionsGranted)
|
||||
) {
|
||||
setStep("permissions");
|
||||
} else {
|
||||
setStep("setup");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("welcome.license.agree")}
|
||||
@@ -281,17 +384,26 @@ export function WelcomeDialog({
|
||||
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" />
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.85, rotate: -8 }}
|
||||
animate={{ opacity: 1, scale: 1, rotate: 0 }}
|
||||
transition={panelTransition}
|
||||
className="flex size-12 items-center justify-center gap-1.5 rounded-full bg-primary/10 text-primary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<LuMic className="size-4 shrink-0" />
|
||||
<LuCamera className="size-4 shrink-0" />
|
||||
</motion.div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight text-balance">
|
||||
{t("welcome.permissions.title")}
|
||||
</h2>
|
||||
<p className="mx-auto max-w-[55ch] text-sm/6 text-pretty text-muted-foreground">
|
||||
<p className="mx-auto max-w-[55ch] text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
|
||||
{t("welcome.permissions.desc")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -337,7 +449,7 @@ export function WelcomeDialog({
|
||||
<LuTriangleAlert className="size-5 shrink-0" />
|
||||
{t("welcome.ready.errorTitle")}
|
||||
</h2>
|
||||
<p className="max-w-[55ch] text-sm/6 text-pretty text-muted-foreground">
|
||||
<p className="max-w-[55ch] text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
|
||||
{setup.error?.stage === "downloading"
|
||||
? t("welcome.ready.errorDownload", {
|
||||
browser: browserName,
|
||||
@@ -371,7 +483,7 @@ export function WelcomeDialog({
|
||||
<h2 className="text-2xl font-semibold tracking-tight text-balance">
|
||||
{t("welcome.ready.title")}
|
||||
</h2>
|
||||
<p className="max-w-[55ch] text-sm/6 text-pretty text-muted-foreground">
|
||||
<p className="max-w-[55ch] text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
|
||||
{setup.phase === "ready"
|
||||
? t("welcome.ready.descReady")
|
||||
: setup.phase === "extracting"
|
||||
@@ -382,40 +494,39 @@ export function WelcomeDialog({
|
||||
|
||||
{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 text-muted-foreground tabular-nums">
|
||||
<SetupProgress
|
||||
value={setup.downloadPercent}
|
||||
label={t("welcome.ready.downloading")}
|
||||
/>
|
||||
<div className="flex items-center justify-between text-base/7 text-muted-foreground tabular-nums sm:text-sm/6">
|
||||
<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 text-muted-foreground tabular-nums">
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-0.5 text-sm text-muted-foreground tabular-nums">
|
||||
<span>
|
||||
{setup.totalBytes != null
|
||||
? t("welcome.ready.stats", {
|
||||
downloaded: formatBytes(setup.downloadedBytes),
|
||||
total: formatBytes(setup.totalBytes),
|
||||
downloaded: formatBytes(
|
||||
setup.downloadedBytes,
|
||||
i18n.language,
|
||||
),
|
||||
total: formatBytes(
|
||||
setup.totalBytes,
|
||||
i18n.language,
|
||||
),
|
||||
})
|
||||
: formatBytes(setup.downloadedBytes)}
|
||||
: formatBytes(setup.downloadedBytes, i18n.language)}
|
||||
</span>
|
||||
{setup.speedBytesPerSec > 0 && (
|
||||
<span>
|
||||
{t("welcome.ready.speed", {
|
||||
speed: formatBytes(setup.speedBytesPerSec),
|
||||
speed: formatBytes(
|
||||
setup.speedBytesPerSec,
|
||||
i18n.language,
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
@@ -424,7 +535,10 @@ export function WelcomeDialog({
|
||||
setup.etaSeconds > 0 && (
|
||||
<span>
|
||||
{t("welcome.ready.timeLeft", {
|
||||
time: formatDuration(setup.etaSeconds),
|
||||
time: formatDuration(
|
||||
setup.etaSeconds,
|
||||
i18n.language,
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
@@ -435,27 +549,17 @@ export function WelcomeDialog({
|
||||
{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 text-muted-foreground tabular-nums">
|
||||
<div className="flex items-center justify-center gap-1.5 text-base/7 text-muted-foreground tabular-nums sm:text-sm/6">
|
||||
<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 text-muted-foreground tabular-nums">
|
||||
<SetupProgress
|
||||
value={setup.extractionPercent}
|
||||
label={t("welcome.ready.extracting")}
|
||||
/>
|
||||
<div className="flex items-center justify-between text-base/7 text-muted-foreground tabular-nums sm:text-sm/6">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<LuLoaderCircle className="size-4 shrink-0 animate-spin" />
|
||||
{t("welcome.ready.extracting")}
|
||||
|
||||
@@ -3,23 +3,15 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Platform = "macos" | "windows" | "linux";
|
||||
|
||||
function detectPlatform(): Platform {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
if (userAgent.includes("mac")) return "macos";
|
||||
if (userAgent.includes("win")) return "windows";
|
||||
return "linux";
|
||||
}
|
||||
import { getCurrentOS, type OperatingSystem } from "@/lib/platform";
|
||||
|
||||
export function WindowDragArea() {
|
||||
const { t } = useTranslation();
|
||||
const [platform, setPlatform] = useState<Platform | null>(null);
|
||||
const [platform, setPlatform] = useState<OperatingSystem | null>(null);
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPlatform(detectPlatform());
|
||||
setPlatform(getCurrentOS());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -64,7 +56,7 @@ export function WindowDragArea() {
|
||||
};
|
||||
|
||||
// Linux: system decorations handle everything
|
||||
if (!platform || platform === "linux") {
|
||||
if (!platform || platform === "linux" || platform === "unknown") {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user