refactor: cleanup

This commit is contained in:
zhom
2026-07-25 22:59:13 +04:00
parent 59a3e5f2a1
commit b9070693ed
49 changed files with 1204 additions and 2208 deletions
@@ -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 (
-25
View File
@@ -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>
);
+3 -8
View File
@@ -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 {
-358
View File
@@ -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>
);
}
+31 -17
View File
@@ -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>
);
}
+2 -1
View File
@@ -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>
+10 -12
View File
@@ -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;
}
+16 -18
View File
@@ -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
+14 -7
View File
@@ -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>
-90
View File
@@ -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,
};
-386
View File
@@ -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,
};
-115
View File
@@ -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>
);
}
+1 -1
View File
@@ -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}
+181 -77
View File
@@ -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")}
+4 -12
View File
@@ -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;
}