forked from baron/baron-sso
Devfront 복사 버튼 및 토스트 알림 추가
This commit is contained in:
54
devfront/src/components/ui/copy-button.tsx
Normal file
54
devfront/src/components/ui/copy-button.tsx
Normal file
@@ -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 (
|
||||||
|
<Button
|
||||||
|
size={size}
|
||||||
|
variant={variant}
|
||||||
|
className={cn("relative z-10", className)}
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Copy</span>
|
||||||
|
{hasCopied ? (
|
||||||
|
<Check className="h-4 w-4 text-emerald-500 transition-all scale-110" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4 transition-all" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
devfront/src/components/ui/toaster.tsx
Normal file
31
devfront/src/components/ui/toaster.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 w-full max-w-[320px]">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-lg border p-4 shadow-lg animate-in slide-in-from-right-full duration-300",
|
||||||
|
t.type === "success" && "bg-emerald-50 border-emerald-200 text-emerald-800 dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-200",
|
||||||
|
t.type === "error" && "bg-rose-50 border-rose-200 text-rose-800 dark:bg-rose-950 dark:border-rose-800 dark:text-rose-200",
|
||||||
|
t.type === "info" && "bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-950 dark:border-blue-800 dark:text-blue-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.type === "success" && <CheckCircle2 className="h-5 w-5 shrink-0" />}
|
||||||
|
{t.type === "error" && <AlertCircle className="h-5 w-5 shrink-0" />}
|
||||||
|
{t.type === "info" && <Info className="h-5 w-5 shrink-0" />}
|
||||||
|
<p className="text-sm font-medium leading-none">{t.message}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
devfront/src/components/ui/use-toast.ts
Normal file
42
devfront/src/components/ui/use-toast.ts
Normal file
@@ -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<Toast[]>(toasts);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
subscribers.push(setState);
|
||||||
|
return () => {
|
||||||
|
subscribers = subscribers.filter((sub) => sub !== setState);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user