From a01ebfd143f62c384b7a5055accbe15654b016d6 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 2 Feb 2026 16:01:39 +0900 Subject: [PATCH] =?UTF-8?q?Devfront=20=EB=B3=B5=EC=82=AC=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EB=B0=8F=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/src/components/ui/copy-button.tsx | 54 ++++++++++++++++++++++ devfront/src/components/ui/toaster.tsx | 31 +++++++++++++ devfront/src/components/ui/use-toast.ts | 42 +++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 devfront/src/components/ui/copy-button.tsx create mode 100644 devfront/src/components/ui/toaster.tsx create mode 100644 devfront/src/components/ui/use-toast.ts diff --git a/devfront/src/components/ui/copy-button.tsx b/devfront/src/components/ui/copy-button.tsx new file mode 100644 index 00000000..83996231 --- /dev/null +++ b/devfront/src/components/ui/copy-button.tsx @@ -0,0 +1,54 @@ +import * as React from "react"; +import { Check, Copy } from "lucide-react"; +import { Button, type ButtonProps } from "./button"; +import { cn } from "../../lib/utils"; + +interface CopyButtonProps extends ButtonProps { + value: string; + onCopy?: () => void; +} + +export function CopyButton({ + value, + onCopy, + className, + variant = "secondary", + size = "icon", + ...props +}: CopyButtonProps) { + const [hasCopied, setHasCopied] = React.useState(false); + + React.useEffect(() => { + if (hasCopied) { + const timer = setTimeout(() => setHasCopied(false), 1500); + return () => clearTimeout(timer); + } + }, [hasCopied]); + + const copyToClipboard = async () => { + try { + await navigator.clipboard.writeText(value); + setHasCopied(true); + if (onCopy) onCopy(); + } catch (err) { + console.error("Failed to copy text: ", err); + } + }; + + return ( + + ); +} diff --git a/devfront/src/components/ui/toaster.tsx b/devfront/src/components/ui/toaster.tsx new file mode 100644 index 00000000..3f951781 --- /dev/null +++ b/devfront/src/components/ui/toaster.tsx @@ -0,0 +1,31 @@ +import * as React from "react"; +import { useToastState } from "./use-toast"; +import { CheckCircle2, AlertCircle, Info, X } from "lucide-react"; +import { cn } from "../../lib/utils"; + +export function Toaster() { + const toasts = useToastState(); + + if (toasts.length === 0) return null; + + return ( +
+ {toasts.map((t) => ( +
+ {t.type === "success" && } + {t.type === "error" && } + {t.type === "info" && } +

{t.message}

+
+ ))} +
+ ); +} diff --git a/devfront/src/components/ui/use-toast.ts b/devfront/src/components/ui/use-toast.ts new file mode 100644 index 00000000..4c19c204 --- /dev/null +++ b/devfront/src/components/ui/use-toast.ts @@ -0,0 +1,42 @@ +import * as React from "react"; + +type ToastType = "success" | "error" | "info"; + +interface Toast { + id: string; + message: string; + type: ToastType; +} + +let subscribers: ((toasts: Toast[]) => void)[] = []; +let toasts: Toast[] = []; + +const notify = () => { + for (const sub of subscribers) { + sub(toasts); + } +}; + +export const toast = (message: string, type: ToastType = "success") => { + const id = Math.random().toString(36).substring(2, 9); + toasts = [...toasts, { id, message, type }]; + notify(); + + setTimeout(() => { + toasts = toasts.filter((t) => t.id !== id); + notify(); + }, 3000); +}; + +export const useToastState = () => { + const [state, setState] = React.useState(toasts); + + React.useEffect(() => { + subscribers.push(setState); + return () => { + subscribers = subscribers.filter((sub) => sub !== setState); + }; + }, []); + + return state; +}; \ No newline at end of file