mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-21 14:51:49 +02:00
feat: add ability to provide feedback
This commit is contained in:
1 parent
bf625fac2c
commit
18a891f709
32 files changed
+2693
-106
No files matched your search
@@ -9,6 +9,8 @@ import {
|
||||
LuChevronsUpDown,
|
||||
LuHand,
|
||||
LuMessageCircleQuestion,
|
||||
LuMessageSquareHeart,
|
||||
LuPaperclip,
|
||||
LuSend,
|
||||
LuUndo2,
|
||||
} from "react-icons/lu";
|
||||
@@ -500,6 +502,44 @@ function ThreadEntry({
|
||||
);
|
||||
}
|
||||
|
||||
if (item.kind === "feedback") {
|
||||
return (
|
||||
<div className="flex max-w-[85%] flex-col gap-1">
|
||||
<span className="flex flex-wrap items-center gap-x-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{nameOf(item.session_id)}
|
||||
</span>
|
||||
<span>{t("agent.thread.feedbackSent")}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
{time}
|
||||
</span>
|
||||
<div className="flex items-start gap-2 rounded-xl bg-foreground/5 px-3 py-2 text-sm text-foreground">
|
||||
<LuMessageSquareHeart
|
||||
className="mt-0.5 size-3.5 shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<p className="break-words whitespace-pre-wrap">{item.text}</p>
|
||||
{item.feedback && (
|
||||
<span className="flex flex-wrap items-center gap-x-1.5 text-[11px] text-muted-foreground">
|
||||
<span>{t(`feedback.kinds.${item.feedback.kind}.label`)}</span>
|
||||
{item.feedback.logs && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<LuPaperclip className="size-3" aria-hidden="true" />
|
||||
{t("agent.thread.feedbackLogs")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.kind === "note") {
|
||||
const delivery =
|
||||
item.state === "delivered"
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
LuInfo,
|
||||
LuKeyboard,
|
||||
LuLightbulb,
|
||||
LuMessageSquareHeart,
|
||||
LuPlay,
|
||||
LuPlug,
|
||||
LuPlus,
|
||||
@@ -67,6 +68,7 @@ const ICONS: Record<ShortcutId, React.ComponentType<{ className?: string }>> = {
|
||||
openPalette: LuKeyboard,
|
||||
openShortcuts: LuKeyboard,
|
||||
openTips: LuLightbulb,
|
||||
sendFeedback: LuMessageSquareHeart,
|
||||
importProfile: FaDownload,
|
||||
goProfiles: LuUser,
|
||||
goProxies: FiWifi,
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
"use client";
|
||||
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LuBug,
|
||||
LuCheck,
|
||||
LuChevronRight,
|
||||
LuFileText,
|
||||
LuHeart,
|
||||
LuLightbulb,
|
||||
LuMail,
|
||||
LuMessageSquareMore,
|
||||
LuSend,
|
||||
} from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { AnimatedDisclosureChevron } from "@/components/ui/animated-disclosure";
|
||||
import { AnimatedSwitch } from "@/components/ui/animated-switch";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { SoftFields } from "@/components/ui/field-variant";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { StepTransition } from "@/components/ui/step-transition";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useInputModality } from "@/hooks/use-input-modality";
|
||||
import type { FeedbackKind } from "@/lib/agent-console";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { formatBytes } from "@/lib/format-bytes";
|
||||
import { MOTION_SPRING_POSITION } from "@/lib/motion";
|
||||
import { isMacOS } from "@/lib/platform";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
/** Mirrors `feedback::MAX_MESSAGE_CHARS`. */
|
||||
const MAX_MESSAGE_CHARS = 5000;
|
||||
/** The counter shows once the message gets this close to the limit. */
|
||||
const COUNTER_FROM = MAX_MESSAGE_CHARS - 500;
|
||||
|
||||
const KINDS: {
|
||||
id: FeedbackKind;
|
||||
Icon: React.ComponentType<{ className?: string }>;
|
||||
}[] = [
|
||||
{ id: "bug", Icon: LuBug },
|
||||
{ id: "idea", Icon: LuLightbulb },
|
||||
{ id: "praise", Icon: LuHeart },
|
||||
{ id: "other", Icon: LuMessageSquareMore },
|
||||
];
|
||||
|
||||
interface SystemInfo {
|
||||
app_version: string;
|
||||
os: string;
|
||||
arch: string;
|
||||
}
|
||||
|
||||
interface FeedbackReceipt {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface FeedbackDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** The signed-in account's email; replies go there. */
|
||||
accountEmail: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feedback to the Donut team. A draft survives closing the dialog and is only
|
||||
* cleared once it has been sent. Logs are on by default for a bug, the one
|
||||
* kind where they nearly always matter, and the person can read exactly what
|
||||
* would be attached before sending.
|
||||
*/
|
||||
export function FeedbackDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
accountEmail,
|
||||
}: FeedbackDialogProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const inputModality = useInputModality();
|
||||
const animate = !reduceMotion && inputModality === "pointer";
|
||||
|
||||
const [kind, setKind] = useState<FeedbackKind>("bug");
|
||||
const [message, setMessage] = useState("");
|
||||
const [includeLogs, setIncludeLogs] = useState(true);
|
||||
const [logsChosen, setLogsChosen] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [logs, setLogs] = useState<string | null>(null);
|
||||
const [logsLoading, setLogsLoading] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sent, setSent] = useState<{ logs: boolean; replyTo: string | null }>();
|
||||
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
|
||||
const messageRef = useRef<HTMLTextAreaElement>(null);
|
||||
const previewRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || systemInfo) return;
|
||||
invoke<SystemInfo>("get_system_info")
|
||||
.then(setSystemInfo)
|
||||
.catch((err: unknown) => {
|
||||
console.warn("Failed to read system info:", err);
|
||||
});
|
||||
}, [isOpen, systemInfo]);
|
||||
|
||||
const chooseKind = useCallback(
|
||||
(next: FeedbackKind) => {
|
||||
setKind(next);
|
||||
if (!logsChosen) setIncludeLogs(next === "bug");
|
||||
},
|
||||
[logsChosen],
|
||||
);
|
||||
|
||||
const loadLogs = useCallback(async () => {
|
||||
setLogsLoading(true);
|
||||
try {
|
||||
setLogs(await invoke<string>("preview_feedback_logs"));
|
||||
} catch (err) {
|
||||
setError(translateBackendError(t, err));
|
||||
} finally {
|
||||
setLogsLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const togglePreview = useCallback(() => {
|
||||
const opening = !previewOpen;
|
||||
setPreviewOpen(opening);
|
||||
if (opening) void loadLogs();
|
||||
}, [loadLogs, previewOpen]);
|
||||
|
||||
// The newest lines are at the end; that is what the reader wants to see.
|
||||
useEffect(() => {
|
||||
if (!previewOpen || logs === null || !previewRef.current) return;
|
||||
previewRef.current.scrollTop = previewRef.current.scrollHeight;
|
||||
}, [logs, previewOpen]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (sent) {
|
||||
setSent(undefined);
|
||||
setMessage("");
|
||||
setKind("bug");
|
||||
setIncludeLogs(true);
|
||||
setLogsChosen(false);
|
||||
setPreviewOpen(false);
|
||||
setLogs(null);
|
||||
}
|
||||
setError(null);
|
||||
onClose();
|
||||
}, [onClose, sent]);
|
||||
|
||||
const canSend = message.trim().length > 0 && !sending;
|
||||
|
||||
const send = useCallback(async () => {
|
||||
if (!message.trim() || sending) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await invoke<FeedbackReceipt>("send_feedback", {
|
||||
kind,
|
||||
message,
|
||||
includeLogs,
|
||||
email: accountEmail ? null : email.trim() || null,
|
||||
locale: i18n.language,
|
||||
});
|
||||
setSent({
|
||||
logs: includeLogs,
|
||||
replyTo: accountEmail ?? (email.trim() || null),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send feedback:", err);
|
||||
setError(translateBackendError(t, err));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [
|
||||
accountEmail,
|
||||
email,
|
||||
i18n.language,
|
||||
includeLogs,
|
||||
kind,
|
||||
message,
|
||||
sending,
|
||||
t,
|
||||
]);
|
||||
|
||||
const modKey = isMacOS() ? "⌘" : "Ctrl";
|
||||
const logsBytes = useMemo(
|
||||
() => (logs === null ? null : new Blob([logs]).size),
|
||||
[logs],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-lg"
|
||||
data-dialog="feedback"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
messageRef.current?.focus();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
if (!sent) void send();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StepTransition
|
||||
transitionKey={sent ? "sent" : "compose"}
|
||||
direction={sent ? 1 : -1}
|
||||
className="grid min-w-0 gap-4"
|
||||
>
|
||||
{sent ? (
|
||||
<div
|
||||
data-slot="feedback-sent"
|
||||
className="grid justify-items-center gap-2 py-6 text-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={animate ? { scale: 0.6 } : false}
|
||||
animate={{ scale: 1 }}
|
||||
transition={animate ? MOTION_SPRING_POSITION : { duration: 0 }}
|
||||
className="mb-2 grid size-14 place-items-center rounded-full bg-success/15 text-success-text"
|
||||
>
|
||||
<LuCheck className="size-7" aria-hidden="true" />
|
||||
</motion.div>
|
||||
<DialogTitle>{t("feedback.success.title")}</DialogTitle>
|
||||
<DialogDescription className="max-w-sm">
|
||||
{sent.logs
|
||||
? t("feedback.success.bodyWithLogs")
|
||||
: t("feedback.success.body")}
|
||||
</DialogDescription>
|
||||
{sent.replyTo && (
|
||||
<p className="max-w-sm text-xs break-words text-muted-foreground">
|
||||
{t("feedback.success.reply", { email: sent.replyTo })}
|
||||
</p>
|
||||
)}
|
||||
<RippleButton
|
||||
autoFocus
|
||||
className="mt-4 min-w-28"
|
||||
onClick={handleClose}
|
||||
>
|
||||
{t("feedback.success.done")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("feedback.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("feedback.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<SoftFields>
|
||||
<RadioGroupPrimitive.Root
|
||||
value={kind}
|
||||
onValueChange={(value) => {
|
||||
chooseKind(value as FeedbackKind);
|
||||
}}
|
||||
aria-label={t("feedback.kindLabel")}
|
||||
className="grid grid-cols-4 gap-2"
|
||||
>
|
||||
{KINDS.map(({ id, Icon }) => (
|
||||
<RadioGroupPrimitive.Item
|
||||
key={id}
|
||||
value={id}
|
||||
data-slot="feedback-kind"
|
||||
data-kind={id}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col items-center gap-1.5 rounded-lg px-2 py-2.5 text-xs font-medium transition-[color,background-color,box-shadow] duration-150 outline-none",
|
||||
"bg-foreground/5 text-muted-foreground hover:bg-foreground/8 hover:text-foreground",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"data-[state=checked]:bg-primary/10 data-[state=checked]:text-foreground data-[state=checked]:inset-ring data-[state=checked]:inset-ring-primary/50",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" aria-hidden="true" />
|
||||
<span className="max-w-full truncate">
|
||||
{t(`feedback.kinds.${id}.label`)}
|
||||
</span>
|
||||
</RadioGroupPrimitive.Item>
|
||||
))}
|
||||
</RadioGroupPrimitive.Root>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Textarea
|
||||
ref={messageRef}
|
||||
data-slot="feedback-message"
|
||||
aria-label={t("feedback.messageLabel")}
|
||||
placeholder={t(`feedback.kinds.${kind}.placeholder`)}
|
||||
value={message}
|
||||
maxLength={MAX_MESSAGE_CHARS}
|
||||
rows={6}
|
||||
className="min-h-32 resize-none"
|
||||
onChange={(event) => {
|
||||
setMessage(event.target.value);
|
||||
}}
|
||||
/>
|
||||
{message.length >= COUNTER_FROM && (
|
||||
<p className="text-right text-[11px] text-muted-foreground tabular-nums">
|
||||
{t("feedback.counter", {
|
||||
count: message.length,
|
||||
max: MAX_MESSAGE_CHARS,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 rounded-lg bg-foreground/5 p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<LuFileText
|
||||
className="mt-0.5 size-4 shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="grid min-w-0 flex-1 gap-0.5">
|
||||
<label
|
||||
htmlFor="feedback-include-logs"
|
||||
className="cursor-pointer text-sm font-medium"
|
||||
>
|
||||
{t("feedback.logs.label")}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("feedback.logs.description")}
|
||||
</p>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
id="feedback-include-logs"
|
||||
data-slot="feedback-include-logs"
|
||||
checked={includeLogs}
|
||||
onCheckedChange={(checked) => {
|
||||
setIncludeLogs(checked);
|
||||
setLogsChosen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{includeLogs && (
|
||||
<div className="pl-7">
|
||||
<button
|
||||
type="button"
|
||||
data-slot="feedback-preview-logs"
|
||||
aria-expanded={previewOpen}
|
||||
onClick={togglePreview}
|
||||
className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
>
|
||||
<AnimatedDisclosureChevron open={previewOpen}>
|
||||
<LuChevronRight className="size-3" />
|
||||
</AnimatedDisclosureChevron>
|
||||
{t("feedback.logs.preview")}
|
||||
{logsBytes !== null && logsBytes > 0 && (
|
||||
<span className="tabular-nums">
|
||||
· {formatBytes(logsBytes)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{previewOpen && (
|
||||
<textarea
|
||||
ref={previewRef}
|
||||
readOnly
|
||||
data-slot="feedback-logs-preview"
|
||||
aria-label={t("feedback.logs.preview")}
|
||||
rows={10}
|
||||
spellCheck={false}
|
||||
value={
|
||||
logsLoading && logs === null
|
||||
? t("feedback.logs.loading")
|
||||
: logs
|
||||
? logs
|
||||
: t("feedback.logs.empty")
|
||||
}
|
||||
className="mt-2 block h-44 w-full resize-none overflow-auto rounded-md bg-background p-2 font-mono text-[10px] leading-relaxed break-all whitespace-pre-wrap text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{accountEmail ? (
|
||||
<p className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<LuMail className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 truncate">
|
||||
{t("feedback.email.signedIn", { email: accountEmail })}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-1.5">
|
||||
<label
|
||||
htmlFor="feedback-email"
|
||||
className="flex items-baseline gap-1.5 text-sm font-medium"
|
||||
>
|
||||
{t("feedback.email.label")}
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{t("feedback.email.optional")}
|
||||
</span>
|
||||
</label>
|
||||
<Input
|
||||
id="feedback-email"
|
||||
data-slot="feedback-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder={t("feedback.email.placeholder")}
|
||||
value={email}
|
||||
onChange={(event) => {
|
||||
setEmail(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-md bg-destructive/10 p-3 text-sm break-words text-destructive-text"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</SoftFields>
|
||||
|
||||
<DialogFooter className="items-center">
|
||||
{systemInfo && (
|
||||
<p className="mr-auto min-w-0 truncate text-[11px] text-muted-foreground">
|
||||
{t("feedback.included", {
|
||||
version: systemInfo.app_version,
|
||||
os: systemInfo.os,
|
||||
arch: systemInfo.arch,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={sending}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
data-slot="feedback-send"
|
||||
isLoading={sending}
|
||||
disabled={!canSend}
|
||||
onClick={() => void send()}
|
||||
>
|
||||
<LuSend className="size-3.5" aria-hidden="true" />
|
||||
{t("feedback.send")}
|
||||
<kbd className="ml-1 hidden rounded bg-primary-foreground/15 px-1 font-sans text-[10px] leading-4 sm:inline">
|
||||
{modKey}↵
|
||||
</kbd>
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</StepTransition>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
LuInfo,
|
||||
LuKeyboard,
|
||||
LuLightbulb,
|
||||
LuMessageSquareHeart,
|
||||
LuPlug,
|
||||
LuPuzzle,
|
||||
LuTrash2,
|
||||
@@ -221,6 +222,7 @@ interface RailNavProps {
|
||||
onOpenAbout: () => void;
|
||||
/** Opens the feature tips catalog. */
|
||||
onOpenTips: () => void;
|
||||
onOpenFeedback: () => 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
|
||||
@@ -297,6 +299,7 @@ export function RailNav({
|
||||
onNavigate,
|
||||
onOpenAbout,
|
||||
onOpenTips,
|
||||
onOpenFeedback,
|
||||
cookieBotRunning = false,
|
||||
agentRequests = 0,
|
||||
}: RailNavProps) {
|
||||
@@ -537,6 +540,28 @@ export function RailNav({
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-slot="rail-open-feedback"
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
onOpenFeedback();
|
||||
}}
|
||||
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">
|
||||
<LuMessageSquareHeart className="size-3" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{t("rail.more.feedback")}
|
||||
</span>
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{t("rail.more.feedbackHint")}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ConsistencyScene,
|
||||
DnsScene,
|
||||
ExtensionsScene,
|
||||
FeedbackScene,
|
||||
GroupsScene,
|
||||
ImportScene,
|
||||
LinkRouteScene,
|
||||
@@ -55,6 +56,8 @@ export function TipScene({ id }: { id: TipId }) {
|
||||
return <ApiScene variant="api" />;
|
||||
case "importProfiles":
|
||||
return <ImportScene />;
|
||||
case "feedback":
|
||||
return <FeedbackScene />;
|
||||
case "cloudBackup":
|
||||
return <CloudSyncScene />;
|
||||
case "cookieBot":
|
||||
|
||||
@@ -743,3 +743,88 @@ export function ImportScene() {
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
const FEEDBACK_ARC = travel(
|
||||
bezier(
|
||||
{ x: 116, y: 64 },
|
||||
{ x: 150, y: 10 },
|
||||
{ x: 214, y: 10 },
|
||||
{ x: 250, y: 62 },
|
||||
16,
|
||||
),
|
||||
0.5,
|
||||
0.78,
|
||||
);
|
||||
|
||||
/** A message is written, the logs clip on, and it flies to the Donut team. */
|
||||
export function FeedbackScene() {
|
||||
const { kf, tr } = useScene(5);
|
||||
return (
|
||||
<Scene>
|
||||
<Window x={16} y={30} width={104} height={100} lines={0} />
|
||||
<path
|
||||
d="M30 60 h72 a6 6 0 0 1 6 6 v26 a6 6 0 0 1 -6 6 h-52 l-10 8 v-8 h-10 a6 6 0 0 1 -6 -6 v-26 a6 6 0 0 1 6 -6 z"
|
||||
className="text-foreground"
|
||||
/>
|
||||
{[52, 40, 28].map((width, index) => (
|
||||
<motion.path
|
||||
key={width}
|
||||
d={`M36 ${70 + index * 9} h${width}`}
|
||||
strokeWidth={2}
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ pathLength: kf([0, 0, 1, 1]) }}
|
||||
transition={tr([0, 0.06 + index * 0.1, 0.15 + index * 0.1, 1], {
|
||||
ease: "linear",
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
<motion.g
|
||||
initial={false}
|
||||
animate={{ opacity: kf([0, 0, 1, 1]), y: kf([8, 8, 0, 0]) }}
|
||||
transition={tr([0, 0.36, 0.46, 1])}
|
||||
>
|
||||
<rect
|
||||
x={92}
|
||||
y={88}
|
||||
width={22}
|
||||
height={28}
|
||||
rx={3}
|
||||
className="fill-background"
|
||||
/>
|
||||
<path d="M97 96 h12 M97 102 h12 M97 108 h7" />
|
||||
</motion.g>
|
||||
<path d="M126 58 C154 14 210 14 238 56" strokeDasharray="2 5" />
|
||||
<motion.g
|
||||
initial={false}
|
||||
animate={{ opacity: kf([0, 0, 1, 1, 0, 0]) }}
|
||||
transition={tr([0, 0.48, 0.5, 0.76, 0.8, 1])}
|
||||
>
|
||||
<motion.g
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ x: kf(FEEDBACK_ARC.cx), y: kf(FEEDBACK_ARC.cy) }}
|
||||
transition={tr(FEEDBACK_ARC.times, { ease: "linear" })}
|
||||
>
|
||||
<path d="M-7 1 L8 -6 L3 8 L0 2 Z M0 2 L8 -6" />
|
||||
</motion.g>
|
||||
</motion.g>
|
||||
<motion.g
|
||||
className="text-foreground"
|
||||
initial={false}
|
||||
animate={{ scale: kf([1, 1, 1.12, 1, 1]) }}
|
||||
transition={tr([0, 0.78, 0.84, 0.9, 1])}
|
||||
>
|
||||
<circle cx={263} cy={84} r={18} />
|
||||
<circle cx={263} cy={84} r={7} />
|
||||
</motion.g>
|
||||
<Check
|
||||
x={286}
|
||||
y={50}
|
||||
className="text-success-text"
|
||||
animate={{ pathLength: kf([0, 0, 1, 1]) }}
|
||||
transition={tr([0, 0.82, 0.92, 1])}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user