mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-02 01:08:41 +02:00
375 lines
13 KiB
TypeScript
375 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
import { useReducedMotion } from "motion/react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { LuArrowLeft, LuExternalLink, LuSearch } from "react-icons/lu";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
|
|
import { Input } from "@/components/ui/input";
|
|
import { StepTransition } from "@/components/ui/step-transition";
|
|
import licenses from "@/generated/licenses.json";
|
|
import xraySource from "@/generated/xray-source.json";
|
|
import { launchDonutClone } from "@/lib/donut-physics";
|
|
import { Logo } from "./icons/logo";
|
|
import { RippleButton } from "./ui/ripple";
|
|
|
|
interface AboutDialogProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
interface SystemInfo {
|
|
app_version: string;
|
|
os: string;
|
|
arch: string;
|
|
portable: boolean;
|
|
}
|
|
|
|
type AboutView = "about" | "licenses";
|
|
|
|
// Flywheel: each click adds spin; past this speed the donut escapes the
|
|
// dialog and bounces around the window (shared physics with the rail egg).
|
|
const SPIN_PER_CLICK = 540; // deg/s
|
|
const ESCAPE_VELOCITY = 2200; // deg/s
|
|
const SPIN_FRICTION = 1.1; // fraction of velocity lost per second
|
|
|
|
export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
|
|
const { t } = useTranslation();
|
|
const reducedMotion = useReducedMotion();
|
|
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
|
|
const [logoFlown, setLogoFlown] = useState(false);
|
|
const [view, setView] = useState<AboutView>("about");
|
|
const [licenseQuery, setLicenseQuery] = useState("");
|
|
|
|
const logoRef = useRef<HTMLButtonElement>(null);
|
|
const rotationRef = useRef(0);
|
|
const velocityRef = useRef(0);
|
|
const rafRef = useRef(0);
|
|
const lastTimeRef = useRef(0);
|
|
const cancelLaunchRef = useRef<(() => void) | null>(null);
|
|
const restoreLicensesButtonFocusRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
invoke<SystemInfo>("get_system_info")
|
|
.then(setSystemInfo)
|
|
.catch(() => {
|
|
setSystemInfo(null);
|
|
});
|
|
}, [isOpen]);
|
|
|
|
const stopSpin = useCallback(() => {
|
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
rafRef.current = 0;
|
|
velocityRef.current = 0;
|
|
}, []);
|
|
|
|
const spinFrame = useCallback(
|
|
(time: number) => {
|
|
const el = logoRef.current;
|
|
if (!el) {
|
|
rafRef.current = 0;
|
|
return;
|
|
}
|
|
const dt = Math.min((time - lastTimeRef.current) / 1000, 0.05);
|
|
lastTimeRef.current = time;
|
|
|
|
rotationRef.current += velocityRef.current * dt;
|
|
velocityRef.current *= Math.max(0, 1 - SPIN_FRICTION * dt);
|
|
el.style.transform = `rotate(${rotationRef.current}deg)`;
|
|
|
|
if (velocityRef.current >= ESCAPE_VELOCITY) {
|
|
// The flywheel wins: the donut tears loose and joins the bounce sim,
|
|
// keeping its spin.
|
|
stopSpin();
|
|
setLogoFlown(true);
|
|
cancelLaunchRef.current = launchDonutClone(el, {
|
|
initialVX: Math.random() > 0.5 ? 420 : -420,
|
|
initialVY: -750,
|
|
spinSpeed: ESCAPE_VELOCITY,
|
|
onExit: () => {
|
|
cancelLaunchRef.current = null;
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (velocityRef.current > 5) {
|
|
rafRef.current = requestAnimationFrame(spinFrame);
|
|
} else {
|
|
rafRef.current = 0;
|
|
velocityRef.current = 0;
|
|
}
|
|
},
|
|
[stopSpin],
|
|
);
|
|
|
|
const handleLogoClick = useCallback(() => {
|
|
if (reducedMotion || logoFlown) return;
|
|
velocityRef.current += SPIN_PER_CLICK;
|
|
if (!rafRef.current) {
|
|
lastTimeRef.current = performance.now();
|
|
rafRef.current = requestAnimationFrame(spinFrame);
|
|
}
|
|
}, [reducedMotion, logoFlown, spinFrame]);
|
|
|
|
const resetLogo = useCallback(() => {
|
|
stopSpin();
|
|
cancelLaunchRef.current?.();
|
|
cancelLaunchRef.current = null;
|
|
rotationRef.current = 0;
|
|
if (logoRef.current) {
|
|
logoRef.current.style.transform = "";
|
|
logoRef.current.style.visibility = "";
|
|
}
|
|
setLogoFlown(false);
|
|
}, [stopSpin]);
|
|
|
|
const handleClose = useCallback(() => {
|
|
resetLogo();
|
|
setView("about");
|
|
setLicenseQuery("");
|
|
restoreLicensesButtonFocusRef.current = false;
|
|
onClose();
|
|
}, [onClose, resetLogo]);
|
|
|
|
const handleLicensesButtonRef = useCallback(
|
|
(node: HTMLButtonElement | null) => {
|
|
if (node && restoreLicensesButtonFocusRef.current) {
|
|
restoreLicensesButtonFocusRef.current = false;
|
|
node.focus();
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleOpenLicenses = useCallback(() => {
|
|
resetLogo();
|
|
setLicenseQuery("");
|
|
setView("licenses");
|
|
}, [resetLogo]);
|
|
|
|
const handleBackToAbout = useCallback(() => {
|
|
restoreLicensesButtonFocusRef.current = true;
|
|
setLicenseQuery("");
|
|
setView("about");
|
|
}, []);
|
|
|
|
const filteredLicenses = useMemo(() => {
|
|
const query = licenseQuery.trim().toLocaleLowerCase();
|
|
if (!query) return licenses;
|
|
return licenses.filter(
|
|
({ name, license }) =>
|
|
name.toLocaleLowerCase().includes(query) ||
|
|
license.toLocaleLowerCase().includes(query),
|
|
);
|
|
}, [licenseQuery]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
cancelLaunchRef.current?.();
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<Dialog
|
|
open={isOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open) handleClose();
|
|
}}
|
|
>
|
|
<DialogContent
|
|
className={
|
|
view === "licenses"
|
|
? "flex h-[min(80dvh,40rem)] max-w-lg flex-col overflow-hidden"
|
|
: "max-w-sm"
|
|
}
|
|
>
|
|
<StepTransition
|
|
transitionKey={view}
|
|
direction={view === "licenses" ? 1 : -1}
|
|
className={
|
|
view === "licenses"
|
|
? "flex min-h-0 flex-1 flex-col gap-4"
|
|
: "grid gap-4"
|
|
}
|
|
>
|
|
{view === "about" ? (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle>{t("about.title")}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="flex flex-col items-center gap-3 py-4">
|
|
<button
|
|
ref={logoRef}
|
|
type="button"
|
|
aria-label={t("header.donutLogo")}
|
|
onClick={handleLogoClick}
|
|
className="grid size-16 cursor-pointer place-items-center rounded-full bg-transparent text-foreground select-none will-change-transform"
|
|
style={logoFlown ? { visibility: "hidden" } : undefined}
|
|
>
|
|
<Logo className="size-14" />
|
|
</button>
|
|
|
|
<div className="text-center">
|
|
<p className="text-lg font-semibold">Donut Browser</p>
|
|
{systemInfo && (
|
|
<>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("about.version", {
|
|
version: systemInfo.app_version,
|
|
})}
|
|
{systemInfo.portable && (
|
|
<span className="ml-1.5 rounded border border-border bg-muted px-1 py-px text-[10px] align-middle">
|
|
{t("about.portableBadge")}
|
|
</span>
|
|
)}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{systemInfo.os} {systemInfo.arch}
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-center text-xs text-muted-foreground">
|
|
{t("about.licenseNotice")}
|
|
</p>
|
|
|
|
<div className="flex flex-wrap justify-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => void openUrl("https://donutbrowser.com")}
|
|
>
|
|
{t("about.website")}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() =>
|
|
void openUrl("https://github.com/zhom/donutbrowser")
|
|
}
|
|
>
|
|
{t("about.github")}
|
|
</Button>
|
|
<Button
|
|
ref={handleLicensesButtonRef}
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleOpenLicenses}
|
|
>
|
|
{t("about.licenses")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end">
|
|
<RippleButton variant="outline" onClick={handleClose}>
|
|
{t("common.buttons.close")}
|
|
</RippleButton>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<DialogHeader className="flex-row items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="-ml-2 size-8"
|
|
aria-label={t("common.buttons.back")}
|
|
onClick={handleBackToAbout}
|
|
>
|
|
<LuArrowLeft aria-hidden="true" />
|
|
</Button>
|
|
<DialogTitle>{t("about.licenses")}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="relative shrink-0">
|
|
<LuSearch
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
|
|
/>
|
|
<Input
|
|
autoFocus
|
|
type="search"
|
|
value={licenseQuery}
|
|
onChange={(event) => {
|
|
setLicenseQuery(event.target.value);
|
|
}}
|
|
aria-label={t("about.searchLicenses")}
|
|
placeholder={t("about.searchLicenses")}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
|
|
<FadingScrollArea
|
|
role="region"
|
|
tabIndex={0}
|
|
aria-label={t("about.licenses")}
|
|
className="-mx-2 min-h-0 flex-1 rounded-md px-2 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
|
>
|
|
{filteredLicenses.length > 0 ? (
|
|
<ul>
|
|
{filteredLicenses.map(({ name, license }) => (
|
|
<li
|
|
key={`${name}-${license}`}
|
|
className="grid grid-cols-[minmax(0,1fr)_minmax(7rem,45%)] items-baseline gap-3 border-b border-border/60 py-2.5 last:border-b-0"
|
|
>
|
|
{name === "Xray-core" ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => void openUrl(xraySource.sourceUrl)}
|
|
aria-label={t("about.openSource", { name })}
|
|
title={t("about.openSource", { name })}
|
|
className="flex min-w-0 items-center gap-1.5 rounded-sm text-left text-sm font-medium text-foreground underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 active:scale-[0.98] motion-reduce:transform-none"
|
|
>
|
|
<span className="truncate">{name}</span>
|
|
<LuExternalLink
|
|
aria-hidden="true"
|
|
className="size-3.5 shrink-0 text-muted-foreground"
|
|
/>
|
|
</button>
|
|
) : (
|
|
<span
|
|
className="truncate text-sm font-medium text-foreground"
|
|
title={name}
|
|
>
|
|
{name}
|
|
</span>
|
|
)}
|
|
<code className="break-words text-right text-xs text-muted-foreground">
|
|
{license}
|
|
</code>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<p
|
|
role="status"
|
|
className="py-12 text-center text-sm text-muted-foreground"
|
|
>
|
|
{t("about.noMatchingLicenses")}
|
|
</p>
|
|
)}
|
|
</FadingScrollArea>
|
|
</>
|
|
)}
|
|
</StepTransition>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|