mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-20 06:28:19 +02:00
feat: add tips
This commit is contained in:
1 parent
3a0e5a41c0
commit
a01901ef07
83 files changed
+8032
-2732
No files matched your search
@@ -12,6 +12,7 @@ import {
|
||||
LuCookie,
|
||||
LuInfo,
|
||||
LuKeyboard,
|
||||
LuLightbulb,
|
||||
LuPlay,
|
||||
LuPlug,
|
||||
LuPlus,
|
||||
@@ -65,6 +66,7 @@ interface CommandPaletteProps {
|
||||
const ICONS: Record<ShortcutId, React.ComponentType<{ className?: string }>> = {
|
||||
openPalette: LuKeyboard,
|
||||
openShortcuts: LuKeyboard,
|
||||
openTips: LuLightbulb,
|
||||
importProfile: FaDownload,
|
||||
goProfiles: LuUser,
|
||||
goProxies: FiWifi,
|
||||
|
||||
@@ -192,6 +192,10 @@ export function ImportProfileDialog({
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [progress, setProgress] = useState<ProfileImportProgress | null>(null);
|
||||
const activeImportItems = useRef<ImportProfileItem[]>([]);
|
||||
// Each run's summary toast gets its own id. Re-using one id after
|
||||
// dismissing it merges the new toast into the one still sliding out, and
|
||||
// a fast retry's summary was never seen.
|
||||
const resultsToastId = useRef<string | null>(null);
|
||||
const [sourceProgress, setSourceProgress] = useState<
|
||||
Record<string, ProfileImportProgress["status"]>
|
||||
>({});
|
||||
@@ -404,7 +408,7 @@ export function ImportProfileDialog({
|
||||
setCurrentStep("importing");
|
||||
setIsImporting(true);
|
||||
setProgress(null);
|
||||
toast.dismiss("profile-import-results");
|
||||
if (resultsToastId.current) toast.dismiss(resultsToastId.current);
|
||||
// A retry covers only the failed subset, so the earlier results are still
|
||||
// the truth for everything else and must not be thrown away.
|
||||
const previous = retryPaths ? result : null;
|
||||
@@ -435,13 +439,14 @@ export function ImportProfileDialog({
|
||||
? toast.warning
|
||||
: toast.error
|
||||
: toast.success;
|
||||
resultsToastId.current = `profile-import-results-${Date.now()}`;
|
||||
notify(
|
||||
t("importProfile.resultsSummary", {
|
||||
imported: combined.imported_count,
|
||||
skipped: combined.skipped_count,
|
||||
failed: combined.failed_count,
|
||||
}),
|
||||
{ id: "profile-import-results" },
|
||||
{ id: resultsToastId.current },
|
||||
);
|
||||
if (
|
||||
batchResult.imported_count > 0 &&
|
||||
@@ -977,6 +982,7 @@ export function ImportProfileDialog({
|
||||
<OperationFlow
|
||||
label={t("importProfile.importingTitle")}
|
||||
active={isImporting ? 1 : 2}
|
||||
busy={isImporting}
|
||||
failed={!isImporting && !!result?.failed_count}
|
||||
steps={[
|
||||
{
|
||||
|
||||
@@ -72,10 +72,26 @@ export function IntegrationDiagnostics({
|
||||
</div>
|
||||
<OperationFlow
|
||||
label={t("appFeedback.connectionTest")}
|
||||
active={busy ? 1 : result?.authorized ? 2 : result?.reachable ? 1 : 0}
|
||||
// The station the probe stopped at: nothing configured stops at
|
||||
// "configured", unreachable at "reachable", a refused token at
|
||||
// "authorized".
|
||||
active={
|
||||
busy
|
||||
? 1
|
||||
: !result
|
||||
? 0
|
||||
: !result.configured
|
||||
? 0
|
||||
: result.reachable === false
|
||||
? 1
|
||||
: 2
|
||||
}
|
||||
busy={busy}
|
||||
failed={
|
||||
!!result &&
|
||||
(result.reachable === false || result.authorized === false)
|
||||
(!result.configured ||
|
||||
result.reachable === false ||
|
||||
result.authorized === false)
|
||||
}
|
||||
steps={[
|
||||
{
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import confetti from "canvas-confetti";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuChevronRight } from "react-icons/lu";
|
||||
import { Logo } from "@/components/icons/logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useInputModality } from "@/hooks/use-input-modality";
|
||||
import { MOTION_EASE_OUT } from "@/lib/motion";
|
||||
import { isMacOS } from "@/lib/platform";
|
||||
import { type TipDefinition, type TipId, tipTextKeys } from "@/lib/tips";
|
||||
|
||||
const spring = { type: "spring", stiffness: 240, damping: 22 } as const;
|
||||
|
||||
/** "pro" reads as "Pro" in the title; an unknown plan keeps its own spelling. */
|
||||
function displayPlan(plan: string): string {
|
||||
return plan ? plan.charAt(0).toLocaleUpperCase() + plan.slice(1) : plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first thing a freshly paid account sees: what the plan unlocked, each
|
||||
* item opening the tip that shows it working. Shown once per account.
|
||||
*/
|
||||
export function PaidWelcomeDialog({
|
||||
open,
|
||||
plan,
|
||||
tips,
|
||||
onOpenChange,
|
||||
onOpenTip,
|
||||
}: {
|
||||
open: boolean;
|
||||
plan: string;
|
||||
/** The plan tips this account is entitled to, in catalog order. */
|
||||
tips: TipDefinition[];
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onOpenTip: (id: TipId) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const modality = useInputModality();
|
||||
const animate = !reduceMotion && modality === "pointer";
|
||||
const mod = isMacOS() ? "⌘" : "Ctrl";
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || reduceMotion || document.hidden) return;
|
||||
const fire = (options: confetti.Options) => {
|
||||
if (document.hidden) return;
|
||||
void confetti({
|
||||
origin: { y: 0.65 },
|
||||
disableForReducedMotion: true,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
fire({ particleCount: 80, spread: 66, startVelocity: 42 });
|
||||
const second = window.setTimeout(
|
||||
() => fire({ particleCount: 40, spread: 100, decay: 0.92 }),
|
||||
220,
|
||||
);
|
||||
return () => window.clearTimeout(second);
|
||||
}, [open, reduceMotion]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
data-slot="paid-welcome"
|
||||
className="p-5 sm:max-w-md sm:p-6"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<motion.div
|
||||
initial={animate ? { scale: 0.92, rotate: -6 } : false}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={animate ? spring : { duration: 0 }}
|
||||
className="text-foreground"
|
||||
>
|
||||
<Logo className="size-12" />
|
||||
</motion.div>
|
||||
<DialogTitle className="text-2xl font-semibold tracking-tight text-balance">
|
||||
{t("paidWelcome.title", { plan: displayPlan(plan) })}
|
||||
</DialogTitle>
|
||||
<p className="max-w-[40ch] text-sm/6 text-pretty text-muted-foreground">
|
||||
{t("paidWelcome.body")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{tips.length > 0 && (
|
||||
<ul
|
||||
data-slot="paid-welcome-list"
|
||||
className="flex min-w-0 flex-col gap-0.5"
|
||||
>
|
||||
{tips.map((tip, index) => {
|
||||
const keys = tipTextKeys(tip.id);
|
||||
return (
|
||||
<li key={tip.id} className="min-w-0">
|
||||
<motion.button
|
||||
type="button"
|
||||
data-slot="paid-welcome-item"
|
||||
data-tip-id={tip.id}
|
||||
onClick={() => onOpenTip(tip.id)}
|
||||
initial={animate ? { y: 8 } : false}
|
||||
animate={{ y: 0 }}
|
||||
transition={{
|
||||
delay: animate ? 0.05 * index : 0,
|
||||
duration: animate ? 0.3 : 0,
|
||||
ease: MOTION_EASE_OUT,
|
||||
}}
|
||||
className="flex w-full min-w-0 cursor-pointer items-center justify-between gap-3 rounded-md px-3 py-2 text-left transition-colors duration-100 hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:outline-ring"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{t(keys.title, { mod })}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{t(keys.body, { mod })}
|
||||
</span>
|
||||
</span>
|
||||
<LuChevronRight
|
||||
aria-hidden="true"
|
||||
className="size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
</motion.button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("paidWelcome.later")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
data-slot="paid-welcome-cta"
|
||||
disabled={tips.length === 0}
|
||||
onClick={() => {
|
||||
if (tips[0]) onOpenTip(tips[0].id);
|
||||
}}
|
||||
>
|
||||
{t("paidWelcome.cta")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export function ProfileLaunchActivity({
|
||||
<OperationFlow
|
||||
label={t("appFeedback.launchActivity")}
|
||||
active={active}
|
||||
busy={!terminal}
|
||||
failed={current.stage === "failed"}
|
||||
steps={[
|
||||
{
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { formatRelativeTime } from "@/lib/flag-utils";
|
||||
import { runProxyCheck, useProxyCheck } from "@/lib/proxy-check-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProxyCheckHistoryEntry, StoredProxy, UdpSupport } from "@/types";
|
||||
|
||||
const COPIED_MARK_MS = 1600;
|
||||
@@ -74,6 +75,83 @@ export function ProxyUdpBadge({ proxy }: { proxy: StoredProxy }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** How many remembered checks the strip draws; the list below keeps them all. */
|
||||
const TREND_BARS = 40;
|
||||
|
||||
/**
|
||||
* The remembered checks as one thin bar per check, oldest on the left, so a
|
||||
* proxy that is slowing down or starting to fail shows as a shape before
|
||||
* anyone reads a line. A failed check has no latency and carries a cross at
|
||||
* the baseline instead of a bar; the newest check is the dark one.
|
||||
*/
|
||||
function LatencyStrip({ history }: { history: ProxyCheckHistoryEntry[] }) {
|
||||
const { t } = useTranslation();
|
||||
const entries = history.slice(0, TREND_BARS).reverse();
|
||||
const peak = entries.reduce(
|
||||
(max, entry) =>
|
||||
entry.ok && typeof entry.latency_ms === "number"
|
||||
? Math.max(max, entry.latency_ms)
|
||||
: max,
|
||||
0,
|
||||
);
|
||||
if (entries.length < 2 || peak === 0) return null;
|
||||
return (
|
||||
<div data-slot="proxy-check-trend" className="space-y-1">
|
||||
<div
|
||||
role="img"
|
||||
aria-label={t("proxyCheck.trendLabel", { count: entries.length })}
|
||||
className="flex h-10 items-end gap-0.5"
|
||||
>
|
||||
{entries.map((entry, index) => {
|
||||
const latest = index === entries.length - 1;
|
||||
const ms =
|
||||
entry.ok && typeof entry.latency_ms === "number"
|
||||
? entry.latency_ms
|
||||
: null;
|
||||
return (
|
||||
<Tooltip key={`${entry.timestamp}-${index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
data-slot="proxy-check-bar"
|
||||
data-ok={entry.ok}
|
||||
className="flex h-full w-1.5 flex-col justify-end"
|
||||
>
|
||||
{ms === null ? (
|
||||
<FiX
|
||||
aria-hidden="true"
|
||||
className="size-1.5 shrink-0 text-destructive-text"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"w-full rounded-t-[3px]",
|
||||
latest ? "bg-foreground" : "bg-muted-foreground/45",
|
||||
)}
|
||||
style={{ height: `${Math.max(8, (ms / peak) * 100)}%` }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{ms === null
|
||||
? t("proxyCheck.historyFailed")
|
||||
: t("proxyCheck.latencyValue", { ms })}
|
||||
{" · "}
|
||||
{formatRelativeTime(entry.timestamp)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{t("proxyCheck.trendPeak", { ms: peak })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One remembered check, as a single line. */
|
||||
function HistoryRow({ entry }: { entry: ProxyCheckHistoryEntry }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -302,7 +380,9 @@ export function ProxyCheckButton({
|
||||
<h3 className="break-words text-sm font-medium">{proxy.name}</h3>
|
||||
<OperationFlow
|
||||
label={t("appFeedback.routeDetails")}
|
||||
active={checking ? 1 : result?.is_valid ? 2 : 0}
|
||||
// A failed check stops at the proxy: the device end is fine.
|
||||
active={checking ? 1 : result?.is_valid ? 2 : result ? 1 : 0}
|
||||
busy={checking}
|
||||
failed={!checking && !!result && !result.is_valid}
|
||||
steps={[
|
||||
{
|
||||
@@ -419,6 +499,7 @@ export function ProxyCheckButton({
|
||||
<h4 className="font-medium text-foreground">
|
||||
{t("proxyCheck.historyTitle")}
|
||||
</h4>
|
||||
{history && <LatencyStrip history={history} />}
|
||||
{history && history.length > 0 ? (
|
||||
<ul className="max-h-48 divide-y divide-border overflow-y-auto">
|
||||
{history.map((entry, index) => (
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
LuCookie,
|
||||
LuInfo,
|
||||
LuKeyboard,
|
||||
LuLightbulb,
|
||||
LuPlug,
|
||||
LuPuzzle,
|
||||
LuTrash2,
|
||||
@@ -218,6 +219,8 @@ interface RailNavProps {
|
||||
currentPage: AppPage;
|
||||
onNavigate: (page: AppPage) => void;
|
||||
onOpenAbout: () => void;
|
||||
/** Opens the feature tips catalog. */
|
||||
onOpenTips: () => void;
|
||||
/**
|
||||
* A remote session is running right now. The Cookie Bot item carries a dot so
|
||||
* the state is legible from every other page — an overnight job you cannot
|
||||
@@ -291,6 +294,7 @@ export function RailNav({
|
||||
currentPage,
|
||||
onNavigate,
|
||||
onOpenAbout,
|
||||
onOpenTips,
|
||||
cookieBotRunning = false,
|
||||
}: RailNavProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -496,6 +500,28 @@ export function RailNav({
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-slot="rail-open-tips"
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
onOpenTips();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-100 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<span className="grid size-5 shrink-0 place-items-center text-muted-foreground">
|
||||
<LuLightbulb className="size-3" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{t("rail.more.tips")}
|
||||
</span>
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{t("rail.more.tipsHint")}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { writeText as writeClipboardText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import Color from "color";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BsCamera, BsMic } from "react-icons/bs";
|
||||
@@ -49,11 +50,13 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { useCommercialTrial } from "@/hooks/use-commercial-trial";
|
||||
import { useInputModality } from "@/hooks/use-input-modality";
|
||||
import { useLanguage } from "@/hooks/use-language";
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { effectivePlanOf } from "@/lib/entitlements";
|
||||
import { MOTION_SPRING_POSITION } from "@/lib/motion";
|
||||
import {
|
||||
applyThemeColors,
|
||||
clearThemeColors,
|
||||
@@ -111,6 +114,8 @@ interface SettingsDialogProps {
|
||||
onClose: () => void;
|
||||
onIntegrationsOpen?: () => void;
|
||||
subPage?: boolean;
|
||||
/** A section to scroll to and focus as soon as the page opens. */
|
||||
initialSection?: string | null;
|
||||
}
|
||||
|
||||
export function SettingsDialog({
|
||||
@@ -118,6 +123,7 @@ export function SettingsDialog({
|
||||
onClose,
|
||||
onIntegrationsOpen,
|
||||
subPage,
|
||||
initialSection = null,
|
||||
}: SettingsDialogProps) {
|
||||
const [settings, setSettings] = useState<AppSettings>({
|
||||
set_as_default_browser: false,
|
||||
@@ -145,8 +151,19 @@ export function SettingsDialog({
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [jumpTo, setJumpTo] = useState<string | null>(null);
|
||||
const [jumpTo, setJumpTo] = useState<string | null>(initialSection);
|
||||
const sectionsRef = useRef<HTMLFieldSetElement>(null);
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
// The section the reader is in, so the nav can point at it. A click pins
|
||||
// its target until the reader scrolls away, because the last sections can
|
||||
// never reach the top of the scroller on their own.
|
||||
const [activeSection, setActiveSection] = useState<string | null>(null);
|
||||
const pinnedSectionRef = useRef<{ id: string; scrollTop: number } | null>(
|
||||
null,
|
||||
);
|
||||
const reduceMotion = useReducedMotion();
|
||||
const inputModality = useInputModality();
|
||||
const animateNav = !reduceMotion && inputModality === "pointer";
|
||||
const [isSettingDefault, setIsSettingDefault] = useState(false);
|
||||
const [isClearingCache, setIsClearingCache] = useState(false);
|
||||
const [isClearingTraffic, setIsClearingTraffic] = useState(false);
|
||||
@@ -884,6 +901,44 @@ export function SettingsDialog({
|
||||
});
|
||||
const sectionVisible = (id: string) =>
|
||||
matchingSections.some(([key]) => key === id);
|
||||
const visibleSectionIds = matchingSections.map(([id]) => id).join(",");
|
||||
|
||||
const syncActiveSection = useCallback(() => {
|
||||
const scroller = scrollerRef.current;
|
||||
const root = sectionsRef.current;
|
||||
if (!scroller || !root) return;
|
||||
const nodes = [
|
||||
...root.querySelectorAll<HTMLElement>("[data-settings-section]"),
|
||||
].filter((node) => !node.hidden);
|
||||
if (nodes.length === 0) {
|
||||
setActiveSection(null);
|
||||
return;
|
||||
}
|
||||
const pinned = pinnedSectionRef.current;
|
||||
if (pinned && Math.abs(scroller.scrollTop - pinned.scrollTop) < 4) {
|
||||
setActiveSection(pinned.id);
|
||||
return;
|
||||
}
|
||||
pinnedSectionRef.current = null;
|
||||
const atBottom =
|
||||
scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2;
|
||||
if (atBottom) {
|
||||
setActiveSection(nodes[nodes.length - 1].dataset.settingsSection ?? null);
|
||||
return;
|
||||
}
|
||||
const threshold = scroller.getBoundingClientRect().top + 12;
|
||||
let current = nodes[0];
|
||||
for (const node of nodes) {
|
||||
if (node.getBoundingClientRect().top <= threshold) current = node;
|
||||
else break;
|
||||
}
|
||||
setActiveSection(current.dataset.settingsSection ?? null);
|
||||
}, []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the section list changes with the search filter and the settings load, and the sync must run again then
|
||||
useEffect(() => {
|
||||
syncActiveSection();
|
||||
}, [syncActiveSection, visibleSectionIds, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!jumpTo) return;
|
||||
@@ -892,6 +947,13 @@ export function SettingsDialog({
|
||||
);
|
||||
section?.scrollIntoView({ block: "start" });
|
||||
section?.focus({ preventScroll: true });
|
||||
if (scrollerRef.current) {
|
||||
pinnedSectionRef.current = {
|
||||
id: jumpTo,
|
||||
scrollTop: scrollerRef.current.scrollTop,
|
||||
};
|
||||
}
|
||||
setActiveSection(jumpTo);
|
||||
setJumpTo(null);
|
||||
}, [jumpTo]);
|
||||
|
||||
@@ -924,21 +986,46 @@ export function SettingsDialog({
|
||||
/>
|
||||
<nav
|
||||
aria-label={t("settings.title")}
|
||||
className="flex flex-wrap gap-x-3 gap-y-1"
|
||||
className="flex flex-wrap gap-x-1 gap-y-1"
|
||||
>
|
||||
{sections.map(([id, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setJumpTo(id);
|
||||
}}
|
||||
className="rounded-sm py-1 text-xs text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
|
||||
>
|
||||
{t(label)}
|
||||
</button>
|
||||
))}
|
||||
{sections.map(([id, label]) => {
|
||||
const active = activeSection === id;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
data-slot="settings-nav-item"
|
||||
data-section={id}
|
||||
aria-current={active ? "location" : undefined}
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setJumpTo(id);
|
||||
}}
|
||||
className={cn(
|
||||
"relative isolate rounded-md px-2 py-1 text-xs transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-ring",
|
||||
active
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
data-slot="settings-nav-indicator"
|
||||
layoutId={
|
||||
animateNav ? "settings-nav-indicator" : undefined
|
||||
}
|
||||
initial={false}
|
||||
transition={
|
||||
animateNav ? MOTION_SPRING_POSITION : { duration: 0 }
|
||||
}
|
||||
className="absolute inset-0 -z-10 rounded-md bg-accent"
|
||||
/>
|
||||
)}
|
||||
{t(label)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -946,6 +1033,8 @@ export function SettingsDialog({
|
||||
side gutters); the width cap lives on the inner column. Fusing
|
||||
them was the dead-wheel-zone bug. */}
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
onScroll={syncActiveSection}
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto",
|
||||
subPage ? "py-2" : "py-4",
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuChevronLeft, LuChevronRight } from "react-icons/lu";
|
||||
import { TipScene } from "@/components/tips/scene-for";
|
||||
import { AnimatedSwitch } from "@/components/ui/animated-switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useInputModality } from "@/hooks/use-input-modality";
|
||||
import { MOTION_EASE_OUT, MOTION_SPRING_POSITION } from "@/lib/motion";
|
||||
import { isMacOS } from "@/lib/platform";
|
||||
import {
|
||||
isPlanTip,
|
||||
type TipAction,
|
||||
type TipDefinition,
|
||||
type TipId,
|
||||
tipTextKeys,
|
||||
} from "@/lib/tips";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TipsDialogMode = "browse" | "single";
|
||||
|
||||
interface TipsDialogProps {
|
||||
open: boolean;
|
||||
/** `browse` shows the whole catalog beside the tip; `single` is one card. */
|
||||
mode: TipsDialogMode;
|
||||
tips: TipDefinition[];
|
||||
seen: string[];
|
||||
initialTipId: TipId | null;
|
||||
/** True when the automatic flow opened the dialog, not the user. */
|
||||
auto: boolean;
|
||||
autoShow: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onTipShown: (id: TipId, auto: boolean) => void;
|
||||
onAutoShowChange: (enabled: boolean) => void;
|
||||
onAction: (action: TipAction) => void;
|
||||
}
|
||||
|
||||
function isTypingTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
return (
|
||||
target.isContentEditable ||
|
||||
["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature tips: a drawing of the feature in motion, a few lines on what it
|
||||
* does for the user, and a button into the place it lives. The catalog on
|
||||
* the left is only there in browse mode; the automatic flow is one card.
|
||||
*/
|
||||
export function TipsDialog({
|
||||
open,
|
||||
mode,
|
||||
tips,
|
||||
seen,
|
||||
initialTipId,
|
||||
auto,
|
||||
autoShow,
|
||||
onOpenChange,
|
||||
onTipShown,
|
||||
onAutoShowChange,
|
||||
onAction,
|
||||
}: TipsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const modality = useInputModality();
|
||||
const animate = !reduceMotion && modality === "pointer";
|
||||
const listId = useId();
|
||||
const autoShowId = useId();
|
||||
const total = tips.length;
|
||||
const initialIndex = Math.max(
|
||||
0,
|
||||
initialTipId ? tips.findIndex((tip) => tip.id === initialTipId) : 0,
|
||||
);
|
||||
const [index, setIndex] = useState(initialIndex);
|
||||
const [direction, setDirection] = useState<1 | -1>(1);
|
||||
const tip = tips[Math.min(index, Math.max(0, total - 1))];
|
||||
const mod = isMacOS() ? "⌘" : "Ctrl";
|
||||
|
||||
// Each tip is reported once as it comes on screen, so the automatic flow
|
||||
// never repeats one and the catalog can tell seen from new.
|
||||
const reportedRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open || !tip || reportedRef.current === tip.id) return;
|
||||
reportedRef.current = tip.id;
|
||||
onTipShown(tip.id, auto && tip.id === initialTipId);
|
||||
}, [open, tip, auto, initialTipId, onTipShown]);
|
||||
|
||||
if (!tip) return null;
|
||||
|
||||
const keys = tipTextKeys(tip.id);
|
||||
const isLast = index >= total - 1;
|
||||
const go = (next: number) => {
|
||||
const clamped = Math.max(0, Math.min(total - 1, next));
|
||||
if (clamped === index) return;
|
||||
setDirection(clamped > index ? 1 : -1);
|
||||
setIndex(clamped);
|
||||
};
|
||||
|
||||
const essentials = tips
|
||||
.map((item, itemIndex) => ({ item, itemIndex }))
|
||||
.filter(({ item }) => !isPlanTip(item));
|
||||
const planTips = tips
|
||||
.map((item, itemIndex) => ({ item, itemIndex }))
|
||||
.filter(({ item }) => isPlanTip(item));
|
||||
const sections = [
|
||||
{ key: "essentials", label: t("tips.essentials"), entries: essentials },
|
||||
...(planTips.length > 0
|
||||
? [{ key: "plan", label: t("tips.planSection"), entries: planTips }]
|
||||
: []),
|
||||
];
|
||||
|
||||
const detail = (
|
||||
<section
|
||||
data-slot="tip-detail"
|
||||
data-tip-id={tip.id}
|
||||
className="flex min-h-0 min-w-0 flex-col gap-4 overflow-y-auto p-5"
|
||||
>
|
||||
<DialogTitle className="pr-6 text-lg font-semibold tracking-tight text-balance">
|
||||
{t(keys.title, { mod })}
|
||||
</DialogTitle>
|
||||
|
||||
<div
|
||||
data-slot="tip-scene-panel"
|
||||
className="relative h-40 overflow-hidden rounded-lg bg-muted/35"
|
||||
>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.div
|
||||
key={tip.id}
|
||||
className="absolute inset-0 p-3"
|
||||
initial={animate ? { x: direction * 24 } : false}
|
||||
animate={{ x: 0 }}
|
||||
exit={animate ? { x: -direction * 24, opacity: 0 } : { opacity: 0 }}
|
||||
transition={{ duration: 0.22, ease: MOTION_EASE_OUT }}
|
||||
>
|
||||
<TipScene id={tip.id} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{isPlanTip(tip) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("tips.planSection")}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm/6 text-pretty text-muted-foreground">
|
||||
{t(keys.body, { mod })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={t("tips.previous")}
|
||||
data-slot="tip-previous"
|
||||
disabled={index === 0}
|
||||
onClick={() => go(index - 1)}
|
||||
>
|
||||
<LuChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<span
|
||||
className="min-w-[5ch] text-center text-xs text-muted-foreground tabular-nums"
|
||||
aria-live="polite"
|
||||
>
|
||||
{t("tips.count", { current: index + 1, total })}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={t("tips.next")}
|
||||
data-slot="tip-next"
|
||||
disabled={isLast}
|
||||
onClick={() => go(index + 1)}
|
||||
>
|
||||
<LuChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-slot="tip-action"
|
||||
onClick={() => onAction(tip.action)}
|
||||
>
|
||||
{t(keys.action)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
data-slot="tip-advance"
|
||||
onClick={() => {
|
||||
if (isLast) onOpenChange(false);
|
||||
else go(index + 1);
|
||||
}}
|
||||
>
|
||||
{t(isLast ? "tips.done" : "tips.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<AnimatedSwitch
|
||||
id={autoShowId}
|
||||
data-slot="tips-auto-show"
|
||||
checked={autoShow}
|
||||
onCheckedChange={onAutoShowChange}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={autoShowId}
|
||||
className="cursor-pointer text-xs font-normal text-muted-foreground"
|
||||
>
|
||||
{t("tips.autoShow")}
|
||||
</Label>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
data-slot="tips-dialog"
|
||||
data-mode={mode}
|
||||
className={cn(
|
||||
"max-h-[calc(100vh-3rem)] gap-0 overflow-hidden p-0",
|
||||
mode === "browse" ? "sm:max-w-2xl" : "sm:max-w-md",
|
||||
)}
|
||||
onKeyDown={(event) => {
|
||||
if (isTypingTarget(event.target)) return;
|
||||
if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
go(index + 1);
|
||||
} else if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
go(index - 1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{mode === "browse" ? (
|
||||
<div className="grid max-h-[calc(100vh-3rem)] min-h-0 sm:grid-cols-[11rem_minmax(0,1fr)]">
|
||||
<aside className="hidden min-h-0 overflow-hidden border-r sm:flex sm:flex-col">
|
||||
<h2 className="px-4 pt-5 pb-1 text-sm font-semibold">
|
||||
{t("tips.title")}
|
||||
</h2>
|
||||
<nav
|
||||
aria-label={t("tips.title")}
|
||||
className="min-h-0 flex-1 overflow-y-auto px-2 pb-3"
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<div key={section.key}>
|
||||
<p className="px-2 pt-3 pb-1 text-[11px] font-medium text-muted-foreground">
|
||||
{section.label}
|
||||
</p>
|
||||
<ul data-slot="tips-list">
|
||||
{section.entries.map(({ item, itemIndex }) => {
|
||||
const active = itemIndex === index;
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="tips-list-item"
|
||||
data-tip-id={item.id}
|
||||
data-seen={seen.includes(item.id)}
|
||||
aria-current={active ? "true" : undefined}
|
||||
onClick={() => go(itemIndex)}
|
||||
className={cn(
|
||||
"relative isolate flex w-full cursor-pointer items-center rounded-md px-2 py-1.5 text-left text-sm transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-ring",
|
||||
active
|
||||
? "text-accent-foreground"
|
||||
: seen.includes(item.id)
|
||||
? "text-muted-foreground hover:text-foreground"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
data-slot="tips-list-indicator"
|
||||
layoutId={
|
||||
animate ? `${listId}-indicator` : undefined
|
||||
}
|
||||
initial={false}
|
||||
transition={
|
||||
animate
|
||||
? MOTION_SPRING_POSITION
|
||||
: { duration: 0 }
|
||||
}
|
||||
className="absolute inset-0 -z-10 rounded-md bg-accent"
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{t(tipTextKeys(item.id).label)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
{detail}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid max-h-[calc(100vh-3rem)] min-h-0">{detail}</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { isMacOS } from "@/lib/platform";
|
||||
import type { TipId } from "@/lib/tips";
|
||||
import {
|
||||
ApiScene,
|
||||
ConsistencyScene,
|
||||
DnsScene,
|
||||
ExtensionsScene,
|
||||
GroupsScene,
|
||||
ImportScene,
|
||||
LinkRouteScene,
|
||||
LockScene,
|
||||
PaletteScene,
|
||||
ProxyRouteScene,
|
||||
SweepScene,
|
||||
SyncScene,
|
||||
TrashScene,
|
||||
} from "./scenes-essentials";
|
||||
import {
|
||||
AgentScene,
|
||||
CloudSyncScene,
|
||||
CookieBotScene,
|
||||
CrossOsScene,
|
||||
RemoteScene,
|
||||
TeamScene,
|
||||
} from "./scenes-plan";
|
||||
|
||||
/** The drawing for one tip. Every tip id has one; the switch is exhaustive. */
|
||||
export function TipScene({ id }: { id: TipId }) {
|
||||
switch (id) {
|
||||
case "dnsBlocklist":
|
||||
return <DnsScene />;
|
||||
case "proxyCheck":
|
||||
return <ProxyRouteScene />;
|
||||
case "groups":
|
||||
return <GroupsScene />;
|
||||
case "commandPalette":
|
||||
return <PaletteScene modLabel={isMacOS() ? "⌘" : "Ctrl"} />;
|
||||
case "fingerprintGate":
|
||||
return <ConsistencyScene />;
|
||||
case "profilePassword":
|
||||
return <LockScene />;
|
||||
case "clearOnClose":
|
||||
return <SweepScene />;
|
||||
case "defaultBrowser":
|
||||
return <LinkRouteScene />;
|
||||
case "extensionGroups":
|
||||
return <ExtensionsScene />;
|
||||
case "selfHostedSync":
|
||||
return <SyncScene />;
|
||||
case "trash":
|
||||
return <TrashScene />;
|
||||
case "localApi":
|
||||
return <ApiScene variant="api" />;
|
||||
case "importProfiles":
|
||||
return <ImportScene />;
|
||||
case "cloudBackup":
|
||||
return <CloudSyncScene />;
|
||||
case "cookieBot":
|
||||
return <CookieBotScene />;
|
||||
case "crossOs":
|
||||
return <CrossOsScene />;
|
||||
case "automation":
|
||||
return <ApiScene variant="run" />;
|
||||
case "agent":
|
||||
return <AgentScene />;
|
||||
case "team":
|
||||
return <TeamScene />;
|
||||
case "remoteControl":
|
||||
return <RemoteScene />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
motion,
|
||||
type TargetAndTransition,
|
||||
type Transition,
|
||||
useReducedMotion,
|
||||
} from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const VIEW_W = 320;
|
||||
export const VIEW_H = 160;
|
||||
|
||||
/**
|
||||
* Shared timing for one looping scene. Every element in a scene keys off the
|
||||
* same duration and its own `times`, so the parts stay in step without any
|
||||
* orchestration. With reduced motion a scene shows its resting frame: the
|
||||
* last keyframe of every value, no loop.
|
||||
*/
|
||||
export function useScene(duration: number) {
|
||||
const reduce = useReducedMotion() ?? false;
|
||||
const kf = <T,>(values: T[]): T | T[] =>
|
||||
reduce ? (values[values.length - 1] as T) : values;
|
||||
const tr = (times: number[], extra?: Transition): Transition =>
|
||||
reduce
|
||||
? { duration: 0 }
|
||||
: {
|
||||
duration,
|
||||
times,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
...extra,
|
||||
};
|
||||
return { reduce, kf, tr };
|
||||
}
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** Points along a cubic bezier, so a dot can travel a drawn wire. */
|
||||
export function bezier(
|
||||
p0: Point,
|
||||
p1: Point,
|
||||
p2: Point,
|
||||
p3: Point,
|
||||
steps: number,
|
||||
): Point[] {
|
||||
const out: Point[] = [];
|
||||
for (let i = 0; i <= steps; i += 1) {
|
||||
const t = i / steps;
|
||||
const mt = 1 - t;
|
||||
out.push({
|
||||
x:
|
||||
mt ** 3 * p0.x +
|
||||
3 * mt ** 2 * t * p1.x +
|
||||
3 * mt * t ** 2 * p2.x +
|
||||
t ** 3 * p3.x,
|
||||
y:
|
||||
mt ** 3 * p0.y +
|
||||
3 * mt ** 2 * t * p1.y +
|
||||
3 * mt * t ** 2 * p2.y +
|
||||
t ** 3 * p3.y,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyframes that hold at the first point, travel through every point between
|
||||
* `from` and `to` (as fractions of the loop), then hold at the last point.
|
||||
*/
|
||||
export function travel(points: Point[], from: number, to: number) {
|
||||
const last = points.length - 1;
|
||||
const times = [
|
||||
0,
|
||||
...points.map((_, index) => from + ((to - from) * index) / last),
|
||||
1,
|
||||
];
|
||||
return {
|
||||
cx: [points[0].x, ...points.map((p) => p.x), points[last].x],
|
||||
cy: [points[0].y, ...points.map((p) => p.y), points[last].y],
|
||||
times,
|
||||
};
|
||||
}
|
||||
|
||||
export function Scene({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
data-slot="tip-scene"
|
||||
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
|
||||
className={cn("h-full w-full text-muted-foreground", className)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** A browser window: a frame, a title bar with one tab, and some text lines. */
|
||||
export function Window({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
lines = 3,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
lines?: number;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const barY = y + 14;
|
||||
return (
|
||||
<g className={className}>
|
||||
<rect x={x} y={y} width={width} height={height} rx={6} />
|
||||
<path d={`M${x} ${barY} H${x + width}`} />
|
||||
<rect
|
||||
x={x + 6}
|
||||
y={y + 4}
|
||||
width={Math.min(24, width / 3)}
|
||||
height={6}
|
||||
rx={2}
|
||||
/>
|
||||
{Array.from({ length: lines }, (_, index) => {
|
||||
const lineY = barY + 14 + index * 12;
|
||||
const lineWidth = (width - 24) * (index % 2 === 0 ? 0.8 : 0.55);
|
||||
return lineY < y + height - 8 ? (
|
||||
<path
|
||||
key={index}
|
||||
d={`M${x + 12} ${lineY} h${lineWidth}`}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
{children}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function Laptop({ x, y }: { x: number; y: number }) {
|
||||
return (
|
||||
<g>
|
||||
<rect x={x} y={y} width={56} height={36} rx={3} />
|
||||
<path d={`M${x - 6} ${y + 41} H${x + 62}`} strokeWidth={2} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function Monitor({ x, y }: { x: number; y: number }) {
|
||||
return (
|
||||
<g>
|
||||
<rect x={x} y={y} width={56} height={40} rx={3} />
|
||||
<path d={`M${x + 28} ${y + 40} v8 M${x + 18} ${y + 48} h20`} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function Server({ x, y }: { x: number; y: number }) {
|
||||
return (
|
||||
<g>
|
||||
<rect x={x} y={y} width={40} height={56} rx={3} />
|
||||
<path d={`M${x} ${y + 19} h40 M${x} ${y + 37} h40`} />
|
||||
{[9, 28, 47].map((offset) => (
|
||||
<circle
|
||||
key={offset}
|
||||
cx={x + 32}
|
||||
cy={y + offset}
|
||||
r={1.5}
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function Person({ cx, cy }: { cx: number; cy: number }) {
|
||||
return (
|
||||
<g>
|
||||
<circle cx={cx} cy={cy - 9} r={6} />
|
||||
<path d={`M${cx - 12} ${cy + 10} a12 12 0 0 1 24 0`} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function Cloud({ cx, cy }: { cx: number; cy: number }) {
|
||||
return (
|
||||
<path
|
||||
d={`M${cx - 22} ${cy + 8} a9 9 0 0 1 2 -17 a14 14 0 0 1 27 -5 a10 10 0 0 1 10 22 z`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Globe({ cx, cy, r }: { cx: number; cy: number; r: number }) {
|
||||
return (
|
||||
<g>
|
||||
<circle cx={cx} cy={cy} r={r} />
|
||||
<ellipse cx={cx} cy={cy} rx={r * 0.42} ry={r} />
|
||||
<path d={`M${cx - r} ${cy} h${r * 2}`} />
|
||||
<path
|
||||
d={`M${cx - r * 0.86} ${cy - r * 0.5} q${r * 0.86} ${r * 0.28} ${r * 1.72} 0`}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function Bin({ x, y }: { x: number; y: number }) {
|
||||
return (
|
||||
<path
|
||||
d={`M${x} ${y} h22 M${x + 3} ${y} v17 a2 2 0 0 0 2 2 h12 a2 2 0 0 0 2 -2 v-17 M${x + 8} ${y} v-3 h6 v3 M${x + 8} ${y + 5} v9 M${x + 14} ${y + 5} v9`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** A padlock; the shackle is its own element so a scene can lift it. */
|
||||
export function Padlock({
|
||||
x,
|
||||
y,
|
||||
shackle,
|
||||
className,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
/** Motion props for the shackle group. */
|
||||
shackle?: { animate: TargetAndTransition; transition: Transition };
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<g className={className}>
|
||||
<rect x={x} y={y} width={16} height={12} rx={2} />
|
||||
<circle cx={x + 8} cy={y + 6} r={1.5} fill="currentColor" stroke="none" />
|
||||
<motion.path
|
||||
d={`M${x + 4} ${y} v-4 a4 4 0 0 1 8 0 v4`}
|
||||
initial={false}
|
||||
{...shackle}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/** A puzzle piece with a bump on top and one on the right, for extensions. */
|
||||
export function puzzlePath(x: number, y: number, size: number): string {
|
||||
const r = size * 0.16;
|
||||
const side = size / 2 - r;
|
||||
return `M${x} ${y} h${side} a${r} ${r} 0 1 1 ${r * 2} 0 h${side} v${side} a${r} ${r} 0 1 1 0 ${r * 2} v${side} h-${size} z`;
|
||||
}
|
||||
|
||||
export function Puzzle({
|
||||
x,
|
||||
y,
|
||||
size,
|
||||
className,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return <path d={puzzlePath(x, y, size)} className={className} />;
|
||||
}
|
||||
|
||||
export function Keycap({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
label,
|
||||
className,
|
||||
animate,
|
||||
transition,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
label: string;
|
||||
className?: string;
|
||||
animate?: TargetAndTransition;
|
||||
transition?: Transition;
|
||||
}) {
|
||||
return (
|
||||
<motion.g
|
||||
className={className}
|
||||
initial={false}
|
||||
animate={animate}
|
||||
transition={transition}
|
||||
>
|
||||
<rect x={x} y={y} width={width} height={18} rx={4} />
|
||||
<text
|
||||
x={x + width / 2}
|
||||
y={y + 9}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={9}
|
||||
fontFamily="inherit"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</motion.g>
|
||||
);
|
||||
}
|
||||
|
||||
export function Cursor({
|
||||
animate,
|
||||
transition,
|
||||
className,
|
||||
}: {
|
||||
animate: TargetAndTransition;
|
||||
transition: Transition;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<motion.path
|
||||
d="M0 0 L0 11 L3 8.5 L5 13 L7 12 L5 7.5 L9 7.5 Z"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
initial={false}
|
||||
animate={animate}
|
||||
transition={transition}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Check({
|
||||
x,
|
||||
y,
|
||||
size = 12,
|
||||
className,
|
||||
animate,
|
||||
transition,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
size?: number;
|
||||
className?: string;
|
||||
animate: TargetAndTransition;
|
||||
transition: Transition;
|
||||
}) {
|
||||
return (
|
||||
<motion.path
|
||||
d={`M${x} ${y} l${size / 3} ${size / 3} l${(size * 2) / 3} -${(size * 2) / 3}`}
|
||||
className={className}
|
||||
strokeWidth={2}
|
||||
initial={false}
|
||||
animate={animate}
|
||||
transition={transition}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,745 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
Bin,
|
||||
bezier,
|
||||
Check,
|
||||
Cursor,
|
||||
Globe,
|
||||
Keycap,
|
||||
Laptop,
|
||||
Padlock,
|
||||
Puzzle,
|
||||
puzzlePath,
|
||||
Scene,
|
||||
Server,
|
||||
travel,
|
||||
useScene,
|
||||
Window,
|
||||
} from "./scene-primitives";
|
||||
|
||||
/**
|
||||
* Every scene here is decorative: the dialog text carries the meaning and the
|
||||
* drawing shows it happening. Scenes loop on their own clock, render their
|
||||
* resting frame under reduced motion, and never hide anything a reader needs.
|
||||
*/
|
||||
|
||||
/** Requests leave a profile; the ones bound for ad and tracker hosts stop at the shield. */
|
||||
export function DnsScene() {
|
||||
const { kf, tr } = useScene(3.6);
|
||||
return (
|
||||
<Scene>
|
||||
<Window x={16} y={32} width={92} height={96} />
|
||||
<path
|
||||
d="M160 42 l22 8 v22 c0 18 -10 30 -22 38 c-12 -8 -22 -20 -22 -38 v-22 z"
|
||||
className="text-foreground"
|
||||
/>
|
||||
{[50, 84, 118].map((y) => (
|
||||
<g key={y}>
|
||||
<path d={`M108 ${y} H236`} strokeDasharray="2 5" />
|
||||
<rect x={236} y={y - 12} width={68} height={24} rx={5} />
|
||||
<path d={`M248 ${y} h24`} strokeWidth={2} />
|
||||
</g>
|
||||
))}
|
||||
<motion.circle
|
||||
r={3.5}
|
||||
cy={50}
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ cx: kf([108, 108, 236, 236]) }}
|
||||
transition={tr([0, 0.08, 0.55, 1])}
|
||||
/>
|
||||
{[84, 118].map((y, index) => {
|
||||
const hit = 0.34 + index * 0.1;
|
||||
return (
|
||||
<g key={y} className="text-destructive-text">
|
||||
<motion.circle
|
||||
cy={y}
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
initial={false}
|
||||
animate={{
|
||||
cx: kf([108, 108, 134, 134, 134]),
|
||||
r: kf([3.5, 3.5, 3.5, 0, 0]),
|
||||
}}
|
||||
transition={tr([0, 0.08, hit, hit + 0.05, 1])}
|
||||
/>
|
||||
<motion.path
|
||||
d={`M130 ${y - 4} l8 8 M138 ${y - 4} l-8 8`}
|
||||
initial={false}
|
||||
animate={{ pathLength: kf([0, 0, 1, 1]) }}
|
||||
transition={tr([0, hit, hit + 0.12, 1])}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
const ROUTE = travel(
|
||||
[
|
||||
...bezier(
|
||||
{ x: 82, y: 104 },
|
||||
{ x: 118, y: 104 },
|
||||
{ x: 128, y: 44 },
|
||||
{ x: 160, y: 44 },
|
||||
8,
|
||||
),
|
||||
...bezier(
|
||||
{ x: 160, y: 44 },
|
||||
{ x: 192, y: 44 },
|
||||
{ x: 204, y: 104 },
|
||||
{ x: 256, y: 104 },
|
||||
8,
|
||||
).slice(1),
|
||||
],
|
||||
0.08,
|
||||
0.66,
|
||||
);
|
||||
|
||||
/** A check travels this device, the proxy, the exit; the exit is confirmed. */
|
||||
export function ProxyRouteScene() {
|
||||
const { kf, tr } = useScene(3.4);
|
||||
return (
|
||||
<Scene>
|
||||
<Laptop x={24} y={86} />
|
||||
<path d="M82 104 C118 104 128 44 160 44 C192 44 204 104 256 104" />
|
||||
<motion.circle
|
||||
cx={160}
|
||||
cy={44}
|
||||
r={10}
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ r: kf([10, 10, 13, 10, 10]) }}
|
||||
transition={tr([0, 0.3, 0.37, 0.44, 1])}
|
||||
/>
|
||||
<circle
|
||||
cx={160}
|
||||
cy={44}
|
||||
r={2}
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
className="text-foreground"
|
||||
/>
|
||||
<Globe cx={272} cy={104} r={16} />
|
||||
<motion.circle
|
||||
r={3.5}
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ cx: kf(ROUTE.cx), cy: kf(ROUTE.cy) }}
|
||||
transition={tr(ROUTE.times, { ease: "linear" })}
|
||||
/>
|
||||
<Check
|
||||
x={280}
|
||||
y={78}
|
||||
className="text-success-text"
|
||||
animate={{ pathLength: kf([0, 0, 1, 1]) }}
|
||||
transition={tr([0, 0.7, 0.82, 1])}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
const COLUMNS = [20, 118, 216];
|
||||
|
||||
/** Profiles settle into groups, then the group keys walk the columns. */
|
||||
export function GroupsScene() {
|
||||
const { kf, tr } = useScene(4.4);
|
||||
return (
|
||||
<Scene>
|
||||
{COLUMNS.map((x, index) => {
|
||||
const press = 0.5 + index * 0.14;
|
||||
return (
|
||||
<g key={x}>
|
||||
<rect x={x} y={26} width={84} height={104} rx={6} />
|
||||
<path d={`M${x + 10} 40 h${28 + index * 10}`} strokeWidth={2} />
|
||||
<Keycap
|
||||
x={x + 33}
|
||||
y={138}
|
||||
width={18}
|
||||
label={String(index + 1)}
|
||||
animate={{ y: kf([0, 0, 2, 0, 0]) }}
|
||||
transition={tr([0, press, press + 0.05, press + 0.1, 1])}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<motion.rect
|
||||
x={20}
|
||||
y={26}
|
||||
width={84}
|
||||
height={104}
|
||||
rx={6}
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ x: kf([0, 0, 98, 196, 196]) }}
|
||||
transition={tr([0, 0.5, 0.64, 0.78, 1])}
|
||||
/>
|
||||
{Array.from({ length: 6 }, (_, index) => {
|
||||
const column = Math.floor(index / 2);
|
||||
const fromY = 52 + index * 12;
|
||||
const toY = 52 + (index % 2) * 12;
|
||||
const start = 0.1 + index * 0.05;
|
||||
return (
|
||||
<motion.rect
|
||||
key={index}
|
||||
x={32}
|
||||
y={toY}
|
||||
width={60}
|
||||
height={8}
|
||||
rx={3}
|
||||
strokeWidth={2}
|
||||
initial={false}
|
||||
animate={{
|
||||
x: kf([0, 0, COLUMNS[column] - 20, COLUMNS[column] - 20]),
|
||||
y: kf([fromY - toY, fromY - toY, 0, 0]),
|
||||
}}
|
||||
transition={tr([0, start, start + 0.2, 1])}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
/** The chord opens the palette; two typed letters narrow it to one entry. */
|
||||
export function PaletteScene({ modLabel }: { modLabel: string }) {
|
||||
const { kf, tr } = useScene(4);
|
||||
const press = (at: number) => ({
|
||||
animate: { y: kf([0, 0, 2, 0, 0]) },
|
||||
transition: tr([0, at, at + 0.04, at + 0.1, 1]),
|
||||
});
|
||||
return (
|
||||
<Scene>
|
||||
<Keycap x={22} y={118} width={30} label={modLabel} {...press(0.06)} />
|
||||
<Keycap x={58} y={118} width={22} label="K" {...press(0.08)} />
|
||||
<rect x={110} y={22} width={190} height={116} rx={8} />
|
||||
<path d="M110 48 H300" />
|
||||
<motion.path
|
||||
d="M124 35 h26"
|
||||
strokeWidth={2}
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ pathLength: kf([0, 0, 1, 1]) }}
|
||||
transition={tr([0, 0.25, 0.4, 1])}
|
||||
/>
|
||||
<motion.path
|
||||
d="M154 30 v10"
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ x: kf([-26, -26, 0, 0]) }}
|
||||
transition={tr([0, 0.25, 0.4, 1])}
|
||||
/>
|
||||
{[62, 80, 98, 116].map((y, index) => (
|
||||
<g key={y}>
|
||||
<circle cx={128} cy={y} r={3} />
|
||||
<path d={`M140 ${y} h${40 + ((index * 23) % 50)}`} strokeWidth={2} />
|
||||
</g>
|
||||
))}
|
||||
<motion.rect
|
||||
x={116}
|
||||
y={54}
|
||||
width={178}
|
||||
height={16}
|
||||
rx={4}
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ y: kf([0, 0, 36, 36]) }}
|
||||
transition={tr([0, 0.45, 0.6, 1])}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
const CLOCK = { cx: 212, cy: 82, r: 16 };
|
||||
|
||||
function hand(deg: number, length: number) {
|
||||
return {
|
||||
x: CLOCK.cx + Math.sin((deg * Math.PI) / 180) * length,
|
||||
y: CLOCK.cy - Math.cos((deg * Math.PI) / 180) * length,
|
||||
};
|
||||
}
|
||||
|
||||
/** The profile clock turns to the exit's timezone; the mismatch mark gives way. */
|
||||
export function ConsistencyScene() {
|
||||
const { kf, tr } = useScene(4);
|
||||
const wrong = hand(-110, 9);
|
||||
const right = hand(60, 9);
|
||||
return (
|
||||
<Scene>
|
||||
<Globe cx={76} cy={82} r={40} />
|
||||
<g className="text-foreground">
|
||||
<circle cx={98} cy={62} r={3.5} fill="currentColor" stroke="none" />
|
||||
<path d="M98 62 v-14" />
|
||||
</g>
|
||||
<path d="M118 62 C140 46 156 46 176 58" strokeDasharray="2 5" />
|
||||
<Window x={176} y={34} width={122} height={94} lines={0}>
|
||||
<circle cx={CLOCK.cx} cy={CLOCK.cy} r={CLOCK.r} />
|
||||
<path
|
||||
d={`M${CLOCK.cx} ${CLOCK.cy} L${CLOCK.cx + 5} ${CLOCK.cy - 11}`}
|
||||
/>
|
||||
<motion.line
|
||||
x1={CLOCK.cx}
|
||||
y1={CLOCK.cy}
|
||||
strokeWidth={2}
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{
|
||||
x2: kf([wrong.x, wrong.x, right.x, right.x]),
|
||||
y2: kf([wrong.y, wrong.y, right.y, right.y]),
|
||||
}}
|
||||
transition={tr([0, 0.3, 0.55, 1])}
|
||||
/>
|
||||
<path d="M244 118 h40" strokeWidth={2} />
|
||||
</Window>
|
||||
<motion.g
|
||||
className="text-warning-text"
|
||||
initial={false}
|
||||
animate={{ opacity: kf([1, 1, 0, 0]) }}
|
||||
transition={tr([0, 0.4, 0.55, 1])}
|
||||
>
|
||||
<path d="M268 68 l9 16 h-18 z" />
|
||||
<path d="M268 74 v5 M268 82 v0.01" strokeWidth={2} />
|
||||
</motion.g>
|
||||
<Check
|
||||
x={262}
|
||||
y={78}
|
||||
className="text-success-text"
|
||||
animate={{ pathLength: kf([0, 0, 1, 1]) }}
|
||||
transition={tr([0, 0.6, 0.75, 1])}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
const CIPHER_LINES = [64, 78, 92, 106];
|
||||
|
||||
/** The padlock drops and the profile's text turns to cipher. */
|
||||
export function LockScene() {
|
||||
const { kf, tr } = useScene(4.2);
|
||||
const swap = tr([0, 0.3, 0.42, 1], { repeatDelay: 0.8 });
|
||||
return (
|
||||
<Scene>
|
||||
<Window x={96} y={30} width={128} height={100} lines={0}>
|
||||
<motion.g
|
||||
initial={false}
|
||||
animate={{ opacity: kf([1, 1, 0.15, 0.15]) }}
|
||||
transition={swap}
|
||||
>
|
||||
{CIPHER_LINES.map((y, index) => (
|
||||
<path
|
||||
key={y}
|
||||
d={`M108 ${y} h${70 - (index % 2) * 24}`}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
</motion.g>
|
||||
<motion.g
|
||||
initial={false}
|
||||
animate={{ opacity: kf([0.15, 0.15, 1, 1]) }}
|
||||
transition={swap}
|
||||
>
|
||||
{CIPHER_LINES.map((y, index) => (
|
||||
<path
|
||||
key={y}
|
||||
d={`M108 ${y} h${70 - (index % 2) * 24}`}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
))}
|
||||
</motion.g>
|
||||
</Window>
|
||||
<Padlock
|
||||
x={200}
|
||||
y={112}
|
||||
className="text-foreground"
|
||||
shackle={{
|
||||
animate: { y: kf([-5, -5, 0, 0]) },
|
||||
transition: tr([0, 0.3, 0.4, 1], { repeatDelay: 0.8 }),
|
||||
}}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
const CRUMBS = [
|
||||
{ x: 100, y: 72, at: 0.4 },
|
||||
{ x: 132, y: 66, at: 0.46 },
|
||||
{ x: 166, y: 76, at: 0.52 },
|
||||
];
|
||||
|
||||
/** The window closes and its cookies and storage fall away. */
|
||||
export function SweepScene() {
|
||||
const { kf, tr } = useScene(4);
|
||||
const fall = (at: number) => tr([0, at, at + 0.22, 1]);
|
||||
return (
|
||||
<Scene>
|
||||
<Window x={70} y={26} width={150} height={108} lines={0}>
|
||||
<motion.path
|
||||
d="M203 40 l8 8 M211 40 l-8 8"
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ pathLength: kf([0, 0, 1, 1]) }}
|
||||
transition={tr([0, 0.28, 0.36, 1])}
|
||||
/>
|
||||
</Window>
|
||||
{CRUMBS.map((crumb) => (
|
||||
<motion.g
|
||||
key={crumb.x}
|
||||
initial={false}
|
||||
animate={{ y: kf([0, 0, 90, 90]), opacity: kf([1, 1, 0, 0]) }}
|
||||
transition={fall(crumb.at)}
|
||||
>
|
||||
<circle cx={crumb.x} cy={crumb.y} r={6} />
|
||||
<circle
|
||||
cx={crumb.x - 2}
|
||||
cy={crumb.y - 2}
|
||||
r={0.8}
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
/>
|
||||
<circle
|
||||
cx={crumb.x + 2.5}
|
||||
cy={crumb.y + 1.5}
|
||||
r={0.8}
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
/>
|
||||
</motion.g>
|
||||
))}
|
||||
{[
|
||||
{ x: 96, at: 0.5 },
|
||||
{ x: 150, at: 0.56 },
|
||||
].map((store) => (
|
||||
<motion.rect
|
||||
key={store.x}
|
||||
x={store.x}
|
||||
y={98}
|
||||
width={30}
|
||||
height={12}
|
||||
rx={3}
|
||||
initial={false}
|
||||
animate={{ y: kf([0, 0, 70, 70]), opacity: kf([1, 1, 0, 0]) }}
|
||||
transition={fall(store.at)}
|
||||
/>
|
||||
))}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
/** A link from another app passes the chooser and opens in the chosen profile. */
|
||||
export function LinkRouteScene() {
|
||||
const { kf, tr } = useScene(4.2);
|
||||
const pop = tr([0, 0.55, 0.7, 1]);
|
||||
return (
|
||||
<Scene>
|
||||
<rect x={16} y={56} width={64} height={48} rx={5} />
|
||||
<path d="M26 70 h44 M26 82 h30" strokeWidth={2} />
|
||||
<path d="M26 94 h24" strokeWidth={2} className="text-foreground" />
|
||||
<path d="M80 94 H112" strokeDasharray="2 5" />
|
||||
<motion.circle
|
||||
r={3}
|
||||
cy={94}
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ cx: kf([80, 80, 112, 112]) }}
|
||||
transition={tr([0, 0.1, 0.3, 1])}
|
||||
/>
|
||||
<Window x={112} y={30} width={104} height={100} lines={0}>
|
||||
{[52, 76, 100].map((y, index) => (
|
||||
<g key={y}>
|
||||
<circle cx={128} cy={y + 8} r={5} />
|
||||
<path d={`M140 ${y + 8} h${28 + index * 12}`} strokeWidth={2} />
|
||||
</g>
|
||||
))}
|
||||
</Window>
|
||||
<motion.rect
|
||||
x={118}
|
||||
y={52}
|
||||
width={92}
|
||||
height={16}
|
||||
rx={4}
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ y: kf([0, 0, 24, 24]) }}
|
||||
transition={tr([0, 0.35, 0.5, 1])}
|
||||
/>
|
||||
<path d="M216 84 H240" strokeDasharray="2 5" />
|
||||
<motion.g
|
||||
initial={false}
|
||||
animate={{ x: kf([12, 12, 0, 0]), y: kf([10, 10, 0, 0]) }}
|
||||
transition={pop}
|
||||
>
|
||||
<motion.rect
|
||||
x={240}
|
||||
y={44}
|
||||
rx={6}
|
||||
initial={false}
|
||||
animate={{
|
||||
width: kf([40, 40, 64, 64]),
|
||||
height: kf([48, 48, 80, 80]),
|
||||
}}
|
||||
transition={pop}
|
||||
/>
|
||||
<motion.path
|
||||
d="M240 58 H304"
|
||||
initial={false}
|
||||
animate={{ pathLength: kf([0.6, 0.6, 1, 1]) }}
|
||||
transition={pop}
|
||||
/>
|
||||
<path d="M250 74 h40 M250 86 h28" strokeWidth={2} />
|
||||
</motion.g>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
/** One extension group; each profile that uses it receives the pieces. */
|
||||
export function ExtensionsScene() {
|
||||
const { kf, tr } = useScene(4);
|
||||
return (
|
||||
<Scene>
|
||||
<rect x={20} y={40} width={88} height={80} rx={8} />
|
||||
<Puzzle x={50} y={66} size={28} className="text-foreground" />
|
||||
{[28, 66, 104].map((y, index) => {
|
||||
const at = 0.25 + index * 0.18;
|
||||
return (
|
||||
<g key={y}>
|
||||
<path
|
||||
d={`M108 80 C130 80 130 ${y + 15} 148 ${y + 15}`}
|
||||
strokeDasharray="2 5"
|
||||
/>
|
||||
<rect x={148} y={y} width={152} height={30} rx={6} />
|
||||
<path
|
||||
d={`M162 ${y + 15} h${50 + (index % 2) * 20}`}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<motion.path
|
||||
d={puzzlePath(268, y + 8, 14)}
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ pathLength: kf([0, 0, 1, 1]) }}
|
||||
transition={tr([0, at, at + 0.15, 1])}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
/** A packet is sealed on its way to the server; the server confirms it. */
|
||||
export function SyncScene() {
|
||||
const { kf, tr } = useScene(4.2);
|
||||
return (
|
||||
<Scene>
|
||||
<Laptop x={24} y={62} />
|
||||
<path d="M86 80 H244" />
|
||||
<Server x={244} y={52} />
|
||||
<motion.rect
|
||||
x={90}
|
||||
y={74}
|
||||
width={12}
|
||||
height={9}
|
||||
rx={2}
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ x: kf([0, 0, 140, 140]) }}
|
||||
transition={tr([0, 0.1, 0.6, 1], { ease: "linear" })}
|
||||
/>
|
||||
<Padlock
|
||||
x={158}
|
||||
y={96}
|
||||
className="text-foreground"
|
||||
shackle={{
|
||||
animate: { y: kf([-5, -5, 0, 0]) },
|
||||
transition: tr([0, 0.3, 0.38, 1]),
|
||||
}}
|
||||
/>
|
||||
<motion.circle
|
||||
cx={276}
|
||||
cy={61}
|
||||
r={1.5}
|
||||
className="text-success-text"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
initial={false}
|
||||
animate={{ r: kf([1.5, 1.5, 3, 3]) }}
|
||||
transition={tr([0, 0.62, 0.7, 1])}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
/** A profile goes to the bin, the retention ring drains, and it comes back. */
|
||||
export function TrashScene() {
|
||||
const { kf, tr } = useScene(5);
|
||||
const move = tr([0, 0.12, 0.34, 0.72, 0.94, 1]);
|
||||
return (
|
||||
<Scene>
|
||||
<Bin x={240} y={70} />
|
||||
<motion.circle
|
||||
cx={251}
|
||||
cy={78}
|
||||
r={22}
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ pathLength: kf([1, 1, 1, 0.12, 1, 1]) }}
|
||||
transition={tr([0, 0.34, 0.4, 0.68, 0.8, 1])}
|
||||
/>
|
||||
<motion.g
|
||||
initial={false}
|
||||
animate={{
|
||||
x: kf([0, 0, 150, 150, 0, 0]),
|
||||
opacity: kf([1, 1, 0.3, 0.3, 1, 1]),
|
||||
}}
|
||||
transition={move}
|
||||
>
|
||||
<rect x={28} y={66} width={104} height={24} rx={6} />
|
||||
<circle cx={44} cy={78} r={6} />
|
||||
<path d="M58 74 h48 M58 82 h30" strokeWidth={2} />
|
||||
</motion.g>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
/** A command in a terminal opens a real profile; `run` also drives it. */
|
||||
export function ApiScene({ variant }: { variant: "api" | "run" }) {
|
||||
const { kf, tr } = useScene(4.4);
|
||||
const pop = tr([0, 0.5, 0.64, 1]);
|
||||
return (
|
||||
<Scene>
|
||||
<Window x={16} y={30} width={140} height={100} lines={0}>
|
||||
<path d="M30 60 l6 5 l-6 5" className="text-foreground" />
|
||||
<motion.path
|
||||
d="M44 65 h72"
|
||||
strokeWidth={2}
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ pathLength: kf([0, 0, 1, 1]) }}
|
||||
transition={tr([0, 0.1, 0.36, 1])}
|
||||
/>
|
||||
<motion.g
|
||||
initial={false}
|
||||
animate={{ opacity: kf([0.2, 0.2, 1, 1]) }}
|
||||
transition={tr([0, 0.42, 0.5, 1])}
|
||||
>
|
||||
<path d="M30 84 h50 M30 96 h34" strokeWidth={2} />
|
||||
</motion.g>
|
||||
</Window>
|
||||
<path d="M156 80 H196" strokeDasharray="2 5" />
|
||||
<motion.circle
|
||||
r={3}
|
||||
cy={80}
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ cx: kf([156, 156, 196, 196]) }}
|
||||
transition={tr([0, 0.38, 0.5, 1])}
|
||||
/>
|
||||
{variant === "run" && (
|
||||
<rect
|
||||
x={212}
|
||||
y={36}
|
||||
width={92}
|
||||
height={70}
|
||||
rx={6}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
)}
|
||||
<motion.g
|
||||
initial={false}
|
||||
animate={{ x: kf([8, 8, 0, 0]), y: kf([8, 8, 0, 0]) }}
|
||||
transition={pop}
|
||||
>
|
||||
<motion.rect
|
||||
x={196}
|
||||
y={44}
|
||||
rx={6}
|
||||
initial={false}
|
||||
animate={{
|
||||
width: kf([60, 60, 108, 108]),
|
||||
height: kf([44, 44, 76, 76]),
|
||||
}}
|
||||
transition={pop}
|
||||
/>
|
||||
<motion.path
|
||||
d="M196 58 H304"
|
||||
initial={false}
|
||||
animate={{ pathLength: kf([0.55, 0.55, 1, 1]) }}
|
||||
transition={pop}
|
||||
/>
|
||||
<path d="M208 74 h48 M208 88 h30" strokeWidth={2} />
|
||||
{variant === "run" && (
|
||||
<Cursor
|
||||
className="text-foreground"
|
||||
animate={{
|
||||
x: kf([232, 232, 262, 262]),
|
||||
y: kf([92, 92, 76, 76]),
|
||||
}}
|
||||
transition={tr([0, 0.7, 0.9, 1])}
|
||||
/>
|
||||
)}
|
||||
</motion.g>
|
||||
<Check
|
||||
x={274}
|
||||
y={100}
|
||||
className="text-success-text"
|
||||
animate={{ pathLength: kf([0, 0, 1, 1]) }}
|
||||
transition={tr([0, 0.66, 0.78, 1])}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
const IMPORT_ARC = bezier(
|
||||
{ x: 126, y: 84 },
|
||||
{ x: 160, y: 26 },
|
||||
{ x: 200, y: 26 },
|
||||
{ x: 263, y: 84 },
|
||||
12,
|
||||
);
|
||||
|
||||
/** Cookies, logins and extensions cross from another browser into a profile. */
|
||||
export function ImportScene() {
|
||||
const { kf, tr } = useScene(4.6);
|
||||
return (
|
||||
<Scene>
|
||||
<Window x={16} y={38} width={110} height={92} lines={4} />
|
||||
<path d="M126 84 C160 26 200 26 263 84" strokeDasharray="2 5" />
|
||||
<rect x={222} y={38} width={82} height={92} rx={6} />
|
||||
<circle cx={263} cy={84} r={18} className="text-foreground" />
|
||||
<circle cx={263} cy={84} r={7} className="text-foreground" />
|
||||
{[0.1, 0.26, 0.42].map((at) => {
|
||||
const trip = travel(IMPORT_ARC, at, at + 0.3);
|
||||
return (
|
||||
<motion.circle
|
||||
key={at}
|
||||
r={4}
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ cx: kf(trip.cx), cy: kf(trip.cy) }}
|
||||
transition={tr(trip.times, { ease: "linear" })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Check
|
||||
x={286}
|
||||
y={50}
|
||||
className="text-success-text"
|
||||
animate={{ pathLength: kf([0, 0, 1, 1]) }}
|
||||
transition={tr([0, 0.76, 0.86, 1])}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
|
||||
import {
|
||||
bezier,
|
||||
Check,
|
||||
Cloud,
|
||||
Cursor,
|
||||
Laptop,
|
||||
Monitor,
|
||||
Padlock,
|
||||
Person,
|
||||
Scene,
|
||||
travel,
|
||||
useScene,
|
||||
Window,
|
||||
} from "./scene-primitives";
|
||||
|
||||
const UP = travel(
|
||||
bezier(
|
||||
{ x: 82, y: 96 },
|
||||
{ x: 100, y: 70 },
|
||||
{ x: 120, y: 56 },
|
||||
{ x: 140, y: 56 },
|
||||
8,
|
||||
),
|
||||
0.08,
|
||||
0.34,
|
||||
);
|
||||
const DOWN = travel(
|
||||
bezier(
|
||||
{ x: 180, y: 56 },
|
||||
{ x: 200, y: 56 },
|
||||
{ x: 220, y: 70 },
|
||||
{ x: 238, y: 96 },
|
||||
8,
|
||||
),
|
||||
0.44,
|
||||
0.7,
|
||||
);
|
||||
|
||||
/** A profile goes up to the cloud from one machine and down to another. */
|
||||
export function CloudSyncScene() {
|
||||
const { kf, tr } = useScene(4.4);
|
||||
return (
|
||||
<Scene>
|
||||
<Laptop x={24} y={78} />
|
||||
<Cloud cx={160} cy={52} />
|
||||
<Monitor x={238} y={74} />
|
||||
<path d="M82 96 C100 70 120 56 140 56" strokeDasharray="2 5" />
|
||||
<path d="M180 56 C200 56 220 70 238 96" strokeDasharray="2 5" />
|
||||
<motion.circle
|
||||
r={3.5}
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ cx: kf(UP.cx), cy: kf(UP.cy) }}
|
||||
transition={tr(UP.times, { ease: "linear" })}
|
||||
/>
|
||||
<motion.circle
|
||||
r={3.5}
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ cx: kf(DOWN.cx), cy: kf(DOWN.cy) }}
|
||||
transition={tr(DOWN.times, { ease: "linear" })}
|
||||
/>
|
||||
<Check
|
||||
x={260}
|
||||
y={92}
|
||||
className="text-success-text"
|
||||
animate={{ pathLength: kf([0, 0, 1, 1]) }}
|
||||
transition={tr([0, 0.72, 0.84, 1])}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
const SKY = travel(
|
||||
bezier(
|
||||
{ x: 40, y: 60 },
|
||||
{ x: 100, y: -4 },
|
||||
{ x: 220, y: -4 },
|
||||
{ x: 280, y: 60 },
|
||||
12,
|
||||
),
|
||||
0.05,
|
||||
0.75,
|
||||
);
|
||||
|
||||
/** The moon crosses the sky while the profile collects cookies and history. */
|
||||
export function CookieBotScene() {
|
||||
const { kf, tr } = useScene(5);
|
||||
return (
|
||||
<Scene>
|
||||
<path d="M24 60 H296" strokeDasharray="2 5" />
|
||||
<motion.g
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{
|
||||
x: kf(SKY.cx.map((x) => x - 40)),
|
||||
y: kf(SKY.cy.map((y) => y - 60)),
|
||||
}}
|
||||
transition={tr(SKY.times, { ease: "linear" })}
|
||||
>
|
||||
<path d="M40 52 a8 8 0 1 0 6 13 a6 6 0 0 1 -6 -13 z" />
|
||||
</motion.g>
|
||||
<Window x={96} y={74} width={128} height={62} lines={0}>
|
||||
{[0, 1, 2, 3, 4].map((index) => {
|
||||
const at = 0.12 + index * 0.13;
|
||||
return (
|
||||
<motion.circle
|
||||
key={index}
|
||||
cx={116 + index * 22}
|
||||
cy={104}
|
||||
initial={false}
|
||||
animate={{ r: kf([0, 0, 5, 5]) }}
|
||||
transition={tr([0, at, at + 0.08, 1])}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<motion.path
|
||||
d="M108 122 h104"
|
||||
strokeWidth={2}
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ pathLength: kf([0, 0, 1, 1]) }}
|
||||
transition={tr([0, 0.08, 0.76, 1], { ease: "linear" })}
|
||||
/>
|
||||
</Window>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
const OS_MARKS = [FaApple, FaWindows, FaLinux];
|
||||
const OS_TIMES = [0, 0.28, 0.34, 0.61, 0.67, 0.94, 1];
|
||||
const OS_VISIBLE = [
|
||||
[1, 1, 0, 0, 0, 0, 1],
|
||||
[0, 0, 1, 1, 0, 0, 0],
|
||||
[0, 0, 0, 0, 1, 1, 0],
|
||||
];
|
||||
|
||||
/** One profile presents as each operating system in turn. */
|
||||
export function CrossOsScene() {
|
||||
const { kf, tr } = useScene(5.4);
|
||||
return (
|
||||
<Scene>
|
||||
<Window x={40} y={30} width={240} height={100} lines={3} />
|
||||
<rect x={214} y={54} width={52} height={52} rx={8} />
|
||||
{OS_MARKS.map((Mark, index) => (
|
||||
<motion.g
|
||||
key={Mark.name}
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ opacity: kf(OS_VISIBLE[index]) }}
|
||||
transition={tr(OS_TIMES)}
|
||||
>
|
||||
<Mark x={226} y={66} width={28} height={28} stroke="none" />
|
||||
</motion.g>
|
||||
))}
|
||||
<motion.path
|
||||
d="M222 114 h12"
|
||||
strokeWidth={2}
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ x: kf([0, 0, 12, 12, 24, 24, 0]) }}
|
||||
transition={tr(OS_TIMES)}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
const AGENT_BUTTONS = [
|
||||
{ x: 36, y: 56 },
|
||||
{ x: 36, y: 82 },
|
||||
{ x: 108, y: 108 },
|
||||
];
|
||||
const AGENT_CLICKS = [0.18, 0.42, 0.66];
|
||||
|
||||
/** The agent clicks through a page and writes each step into a recipe. */
|
||||
export function AgentScene() {
|
||||
const { kf, tr } = useScene(5.2);
|
||||
return (
|
||||
<Scene>
|
||||
<Window x={16} y={26} width={168} height={108} lines={0}>
|
||||
{AGENT_BUTTONS.map((button, index) => {
|
||||
const at = AGENT_CLICKS[index];
|
||||
return (
|
||||
<g key={button.y}>
|
||||
<rect x={button.x} y={button.y} width={52} height={14} rx={4} />
|
||||
<motion.rect
|
||||
x={button.x}
|
||||
y={button.y}
|
||||
width={52}
|
||||
height={14}
|
||||
rx={4}
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ opacity: kf([0, 0, 0.9, 0.9]) }}
|
||||
transition={tr([0, at, at + 0.05, 1])}
|
||||
/>
|
||||
<Check
|
||||
x={button.x + 58}
|
||||
y={button.y + 6}
|
||||
size={9}
|
||||
className="text-success-text"
|
||||
animate={{ pathLength: kf([0, 0, 1, 1]) }}
|
||||
transition={tr([0, at + 0.04, at + 0.12, 1])}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</Window>
|
||||
<Cursor
|
||||
className="text-foreground"
|
||||
animate={{
|
||||
x: kf([150, 150, 58, 58, 58, 58, 130, 130]),
|
||||
y: kf([120, 120, 60, 60, 86, 86, 112, 112]),
|
||||
}}
|
||||
transition={tr([
|
||||
0,
|
||||
0.06,
|
||||
AGENT_CLICKS[0],
|
||||
AGENT_CLICKS[0] + 0.1,
|
||||
AGENT_CLICKS[1],
|
||||
AGENT_CLICKS[1] + 0.1,
|
||||
AGENT_CLICKS[2],
|
||||
1,
|
||||
])}
|
||||
/>
|
||||
<Window x={204} y={26} width={100} height={108} lines={0}>
|
||||
{AGENT_CLICKS.map((at, index) => (
|
||||
<g key={at}>
|
||||
<circle
|
||||
cx={214}
|
||||
cy={58 + index * 22}
|
||||
r={2}
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
/>
|
||||
<motion.path
|
||||
d={`M222 ${58 + index * 22} h${60 - index * 10}`}
|
||||
strokeWidth={2}
|
||||
initial={false}
|
||||
animate={{ pathLength: kf([0, 0, 1, 1]) }}
|
||||
transition={tr([0, at + 0.08, at + 0.2, 1])}
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</Window>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
/** One teammate holds the profile lock, releases it, and the other takes it. */
|
||||
export function TeamScene() {
|
||||
const { kf, tr } = useScene(5);
|
||||
return (
|
||||
<Scene>
|
||||
<Person cx={44} cy={84} />
|
||||
<Person cx={276} cy={84} />
|
||||
<Window x={112} y={48} width={96} height={64} lines={2} />
|
||||
<path d="M58 80 H112 M208 80 H262" />
|
||||
<motion.path
|
||||
d="M58 80 H112"
|
||||
className="text-foreground"
|
||||
strokeWidth={2.5}
|
||||
initial={false}
|
||||
animate={{ pathLength: kf([1, 1, 0, 0, 0]) }}
|
||||
transition={tr([0, 0.4, 0.5, 0.52, 1])}
|
||||
/>
|
||||
<motion.path
|
||||
d="M262 80 H208"
|
||||
className="text-foreground"
|
||||
strokeWidth={2.5}
|
||||
initial={false}
|
||||
animate={{ pathLength: kf([0, 0, 0, 1, 1]) }}
|
||||
transition={tr([0, 0.55, 0.6, 0.72, 1])}
|
||||
/>
|
||||
<Padlock
|
||||
x={152}
|
||||
y={116}
|
||||
className="text-foreground"
|
||||
shackle={{
|
||||
animate: { y: kf([0, 0, -5, -5, 0, 0]) },
|
||||
transition: tr([0, 0.42, 0.5, 0.62, 0.7, 1]),
|
||||
}}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
/** A request from the website crosses the bridge, drives the desktop, and reports back. */
|
||||
export function RemoteScene() {
|
||||
const { kf, tr } = useScene(4.8);
|
||||
return (
|
||||
<Scene>
|
||||
<Cloud cx={64} cy={64} />
|
||||
<Monitor x={232} y={62} />
|
||||
<path d="M92 82 H232" strokeDasharray="2 5" />
|
||||
<motion.rect
|
||||
x={96}
|
||||
y={78}
|
||||
width={12}
|
||||
height={8}
|
||||
rx={2}
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ x: kf([0, 0, 124, 124, 0, 0]) }}
|
||||
transition={tr([0, 0.08, 0.34, 0.56, 0.82, 1], { ease: "linear" })}
|
||||
/>
|
||||
<Cursor
|
||||
className="text-foreground"
|
||||
animate={{ x: kf([246, 246, 268, 268]), y: kf([88, 88, 74, 74]) }}
|
||||
transition={tr([0, 0.36, 0.5, 1])}
|
||||
/>
|
||||
<motion.circle
|
||||
cx={274}
|
||||
cy={80}
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ r: kf([0, 0, 4, 7, 0, 0]) }}
|
||||
transition={tr([0, 0.5, 0.53, 0.58, 0.62, 1])}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuLock, LuRotateCcw, LuTrash2 } from "react-icons/lu";
|
||||
@@ -27,10 +28,12 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useInputModality } from "@/hooks/use-input-modality";
|
||||
import { useTrashEvents } from "@/hooks/use-trash-events";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { getBrowserDisplayName } from "@/lib/browser-utils";
|
||||
import { formatBytes } from "@/lib/format-bytes";
|
||||
import { MOTION_EASE_OUT } from "@/lib/motion";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BrowserProfile, TrashedProfileSummary } from "@/types";
|
||||
@@ -48,6 +51,51 @@ function daysUntil(expiresAt: number, nowSeconds: number): number {
|
||||
return Math.max(0, Math.ceil((expiresAt - nowSeconds) / SECONDS_PER_DAY));
|
||||
}
|
||||
|
||||
/**
|
||||
* The retention period as a ring that drains from the top: what is left of
|
||||
* it reads at a glance beside the day count, and an entry about to go is the
|
||||
* one whose ring is nearly gone.
|
||||
*/
|
||||
function RetentionRing({
|
||||
deletedAt,
|
||||
expiresAt,
|
||||
nowSeconds,
|
||||
warning,
|
||||
}: {
|
||||
deletedAt: number;
|
||||
expiresAt: number;
|
||||
nowSeconds: number;
|
||||
warning: boolean;
|
||||
}) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
const modality = useInputModality();
|
||||
const animate = !reduceMotion && modality === "pointer";
|
||||
const total = Math.max(1, expiresAt - deletedAt);
|
||||
const left = Math.max(0, Math.min(1, (expiresAt - nowSeconds) / total));
|
||||
return (
|
||||
<svg
|
||||
data-slot="trash-retention-ring"
|
||||
viewBox="0 0 16 16"
|
||||
className="size-3.5 shrink-0 -rotate-90"
|
||||
fill="none"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx={8} cy={8} r={6} className="stroke-border" />
|
||||
<motion.circle
|
||||
cx={8}
|
||||
cy={8}
|
||||
r={6}
|
||||
className={warning ? "stroke-warning-text" : "stroke-foreground"}
|
||||
initial={animate ? { pathLength: 0 } : false}
|
||||
animate={{ pathLength: left }}
|
||||
transition={{ duration: animate ? 0.6 : 0, ease: MOTION_EASE_OUT }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrashPage({ isOpen, onClose, subPage }: TrashPageProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { entries, isLoading, error } = useTrashEvents();
|
||||
@@ -265,9 +313,17 @@ export function TrashPage({ isOpen, onClose, subPage }: TrashPageProps) {
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{daysLeft === 0
|
||||
? t("trash.expiresToday")
|
||||
: t("trash.expiresIn", { count: daysLeft })}
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<RetentionRing
|
||||
deletedAt={entry.deleted_at}
|
||||
expiresAt={entry.expires_at}
|
||||
nowSeconds={nowSeconds}
|
||||
warning={daysLeft <= 1}
|
||||
/>
|
||||
{daysLeft === 0
|
||||
? t("trash.expiresToday")
|
||||
: t("trash.expiresIn", { count: daysLeft })}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden @xl:table-cell text-right text-sm tabular-nums text-muted-foreground">
|
||||
{formatBytes(entry.size_bytes)}
|
||||
|
||||
@@ -264,7 +264,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.
|
||||
"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 bg-background p-6",
|
||||
"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 bg-background p-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -12,26 +12,56 @@ export interface OperationStep {
|
||||
detail: ReactNode;
|
||||
}
|
||||
|
||||
/** A measured relationship or operation, with labels independent of its marker. */
|
||||
type NodeState = "done" | "active" | "failed" | "pending";
|
||||
|
||||
/**
|
||||
* A measured relationship or operation as a row of stations.
|
||||
*
|
||||
* Every station before the current one is settled and wears a check; the
|
||||
* current one is a ring, or a cross when the operation failed there; the
|
||||
* ones after it wait as small dots. The wire between stations fills as they
|
||||
* settle, and while the operation is busy a pulse travels the wire into the
|
||||
* station being worked on. Reaching the last station with nothing failed
|
||||
* settles the whole row.
|
||||
*/
|
||||
export function OperationFlow({
|
||||
steps,
|
||||
active,
|
||||
failed = false,
|
||||
busy = false,
|
||||
label,
|
||||
}: {
|
||||
steps: OperationStep[];
|
||||
/** The station the operation is at. */
|
||||
active: number;
|
||||
/** The operation stopped at `active`. */
|
||||
failed?: boolean;
|
||||
/** The operation is still working towards `active`. */
|
||||
busy?: boolean;
|
||||
label: string;
|
||||
}) {
|
||||
const reduced = useReducedMotion();
|
||||
const modality = useInputModality();
|
||||
const current = Math.max(0, Math.min(steps.length - 1, active));
|
||||
const animate = !reduced && modality !== "keyboard";
|
||||
const last = steps.length - 1;
|
||||
const current = Math.max(0, Math.min(last, active));
|
||||
if (steps.length < 2) return null;
|
||||
|
||||
const complete = !busy && !failed && current === last;
|
||||
const stateOf = (index: number): NodeState => {
|
||||
if (complete || index < current) return "done";
|
||||
if (index === current) return failed ? "failed" : "active";
|
||||
return "pending";
|
||||
};
|
||||
const at = (index: number) => `${(index / last) * 100}%`;
|
||||
const settle = { duration: animate ? 0.35 : 0, ease: MOTION_EASE_OUT };
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="operation-flow"
|
||||
data-state={
|
||||
failed ? "failed" : busy ? "busy" : complete ? "done" : "active"
|
||||
}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
className="min-w-0 py-2"
|
||||
@@ -39,30 +69,121 @@ export function OperationFlow({
|
||||
<div
|
||||
data-slot="operation-track"
|
||||
aria-hidden="true"
|
||||
className="relative mb-3 h-3"
|
||||
className="relative mb-3 h-4"
|
||||
style={{ marginInline: `${50 / steps.length}%` }}
|
||||
>
|
||||
<span className="absolute inset-x-0 top-[5px] h-0.5 rounded-full bg-border" />
|
||||
{steps.map((step, index) => (
|
||||
<span
|
||||
key={step.id}
|
||||
className="absolute top-1 size-1 -translate-x-1/2 rounded-full bg-muted-foreground"
|
||||
style={{ left: `${(index / (steps.length - 1)) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
<motion.span
|
||||
data-slot="operation-marker"
|
||||
initial={false}
|
||||
animate={{ left: `${(current / (steps.length - 1)) * 100}%` }}
|
||||
transition={{
|
||||
duration: reduced || modality === "keyboard" ? 0 : 0.22,
|
||||
ease: MOTION_EASE_OUT,
|
||||
}}
|
||||
className={cn(
|
||||
"absolute top-0 size-3 -translate-x-1/2 rounded-full bg-foreground",
|
||||
failed && "bg-destructive",
|
||||
)}
|
||||
/>
|
||||
{steps.slice(0, -1).map((step, index) => {
|
||||
// The wire into a station fills once that station is settled.
|
||||
const filled =
|
||||
complete ||
|
||||
index + 1 < current ||
|
||||
(index + 1 === current && !busy && !failed);
|
||||
const pulsing = busy && index === current - 1;
|
||||
return (
|
||||
<span
|
||||
key={step.id}
|
||||
data-slot="operation-wire"
|
||||
data-filled={filled}
|
||||
className="absolute top-1/2 h-0.5 -translate-y-1/2 rounded-full bg-border"
|
||||
style={{ left: at(index), width: `${100 / last}%` }}
|
||||
>
|
||||
<motion.span
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-foreground"
|
||||
initial={false}
|
||||
animate={{ width: filled ? "100%" : "0%" }}
|
||||
transition={settle}
|
||||
/>
|
||||
{pulsing && (
|
||||
<motion.span
|
||||
data-slot="operation-pulse"
|
||||
className="absolute top-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground"
|
||||
initial={false}
|
||||
animate={{ left: animate ? ["0%", "100%"] : "100%" }}
|
||||
transition={
|
||||
animate
|
||||
? {
|
||||
duration: 1.1,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
}
|
||||
: { duration: 0 }
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{steps.map((step, index) => {
|
||||
const state = stateOf(index);
|
||||
return (
|
||||
<span
|
||||
key={step.id}
|
||||
data-slot={
|
||||
index === current ? "operation-marker" : "operation-node"
|
||||
}
|
||||
data-node-state={state}
|
||||
className={cn(
|
||||
"absolute top-1/2 grid -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full",
|
||||
state === "pending"
|
||||
? "size-1.5 bg-muted-foreground"
|
||||
: "size-3.5",
|
||||
state === "done" && "bg-foreground text-background",
|
||||
state === "active" &&
|
||||
"border-2 border-foreground bg-background",
|
||||
state === "failed" &&
|
||||
"border-2 border-destructive bg-background text-destructive-text",
|
||||
)}
|
||||
style={{ left: at(index) }}
|
||||
>
|
||||
{state === "done" && (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 10 10"
|
||||
className="size-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<motion.path
|
||||
d="M2 5.2 L4.2 7.4 L8 3"
|
||||
initial={animate ? { pathLength: 0 } : false}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{
|
||||
duration: animate ? 0.25 : 0,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{state === "failed" && (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 10 10"
|
||||
className="size-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<motion.path
|
||||
d="M2.5 2.5 L7.5 7.5 M7.5 2.5 L2.5 7.5"
|
||||
initial={animate ? { pathLength: 0 } : false}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{
|
||||
duration: animate ? 0.25 : 0,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{state === "active" && (
|
||||
<span className="size-1 rounded-full bg-foreground" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ol
|
||||
className="grid"
|
||||
@@ -70,18 +191,32 @@ export function OperationFlow({
|
||||
gridTemplateColumns: `repeat(${steps.length}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{steps.map((step, index) => (
|
||||
<li
|
||||
key={step.id}
|
||||
aria-current={index === current ? "step" : undefined}
|
||||
className="min-w-0 px-1.5 text-center"
|
||||
>
|
||||
<p className="break-words text-xs font-medium">{step.label}</p>
|
||||
<div className="mt-1 break-words text-xs leading-relaxed text-muted-foreground">
|
||||
{step.detail}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{steps.map((step, index) => {
|
||||
const state = stateOf(index);
|
||||
return (
|
||||
<li
|
||||
key={step.id}
|
||||
aria-current={index === current ? "step" : undefined}
|
||||
data-node-state={state}
|
||||
className="min-w-0 px-1.5 text-center"
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"break-words text-xs font-medium transition-colors duration-200",
|
||||
state === "pending"
|
||||
? "text-muted-foreground"
|
||||
: "text-foreground",
|
||||
state === "failed" && "text-destructive-text",
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</p>
|
||||
<div className="mt-1 break-words text-xs leading-relaxed text-muted-foreground">
|
||||
{step.detail}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user