wing-ops/frontend/src/pages/design/float/FloatToastContent.tsx

428 lines
16 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// FloatToastContent.tsx — Toast 컴포넌트 카탈로그
import { useState, useEffect } from 'react';
import type { DesignTheme } from '../designTheme';
interface FloatToastContentProps {
theme: DesignTheme;
}
type ToastType = 'success' | 'error' | 'info' | 'warning';
interface ToastItem {
id: number;
type: ToastType;
message: string;
progress: number;
}
const TOAST_CONFIG: Record<ToastType, { color: string; bg: string; icon: string; label: string }> =
{
success: { color: '#22c55e', bg: 'rgba(34,197,94,0.12)', icon: '✓', label: 'Success' },
error: { color: '#ef4444', bg: 'rgba(239,68,68,0.12)', icon: '✕', label: 'Error' },
info: { color: '#06b6d4', bg: 'rgba(6,182,212,0.12)', icon: '', label: 'Info' },
warning: { color: '#eab308', bg: 'rgba(234,179,8,0.12)', icon: '⚠', label: 'Warning' },
};
const DEMO_MESSAGES: Record<ToastType, string> = {
success: '저장이 완료되었습니다.',
error: '요청 처리 중 오류가 발생했습니다.',
info: '시뮬레이션이 시작되었습니다.',
warning: '미저장 변경사항이 있습니다.',
};
const TOAST_DURATION = 3000;
let toastIdCounter = 0;
export const FloatToastContent = ({ theme }: FloatToastContentProps) => {
const t = theme;
const isDark = t.mode === 'dark';
const [toasts, setToasts] = useState<ToastItem[]>([]);
const addToast = (type: ToastType) => {
const id = ++toastIdCounter;
setToasts((prev) => [...prev, { id, type, message: DEMO_MESSAGES[type], progress: 100 }]);
};
const removeToast = (id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
};
useEffect(() => {
if (toasts.length === 0) return;
const interval = setInterval(() => {
setToasts((prev) =>
prev
.map((toast) => ({ ...toast, progress: toast.progress - 100 / (TOAST_DURATION / 100) }))
.filter((toast) => toast.progress > 0),
);
}, 100);
return () => clearInterval(interval);
}, [toasts.length]);
const toastBg = isDark ? '#1b1f2c' : '#ffffff';
const toastBorder = isDark ? 'rgba(66,71,84,0.30)' : '#e2e8f0';
return (
<div className="px-8 py-10 flex flex-col gap-12 max-w-[1200px]">
{/* ── 개요 ── */}
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<h2 className="font-sans text-xl font-bold" style={{ color: t.textPrimary }}>
Toast
</h2>
<span
className="font-mono text-caption rounded px-2 py-0.5"
style={{
backgroundColor: isDark ? 'rgba(234,179,8,0.10)' : 'rgba(234,179,8,0.08)',
color: '#eab308',
}}
>
</span>
</div>
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
.
<code
className="font-mono text-xs px-1.5 py-0.5 mx-1 rounded"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
color: t.textAccent,
}}
>
fixed bottom-right
</code>
. {' '}
<code
className="font-mono text-xs px-1.5 py-0.5 mx-1 rounded"
style={{
backgroundColor: isDark ? 'rgba(239,68,68,0.08)' : 'rgba(239,68,68,0.05)',
color: '#ef4444',
}}
>
window.alert
</code>
console.log로 .
</p>
</div>
{/* ── Live Preview ── */}
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
Live Preview
</h3>
<span
className="font-mono text-caption px-2 py-0.5 rounded"
style={{
backgroundColor: isDark ? 'rgba(34,197,94,0.10)' : 'rgba(34,197,94,0.08)',
color: '#22c55e',
}}
>
interactive
</span>
</div>
<div
className="rounded-lg border border-solid p-5 flex flex-col gap-4"
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
<p className="font-korean text-xs" style={{ color: t.textMuted }}>
Toast가 . 3 .
</p>
<div className="flex gap-2 flex-wrap">
{(Object.keys(TOAST_CONFIG) as ToastType[]).map((type) => {
const cfg = TOAST_CONFIG[type];
return (
<button
key={type}
type="button"
onClick={() => addToast(type)}
className="px-4 py-2 rounded border border-solid font-mono text-caption font-medium transition-opacity hover:opacity-80"
style={{
backgroundColor: cfg.bg,
borderColor: `${cfg.color}40`,
color: cfg.color,
}}
>
{cfg.icon} {cfg.label}
</button>
);
})}
</div>
{toasts.length > 0 && (
<p className="font-mono text-caption" style={{ color: t.textMuted }}>
Toast: {toasts.length}
</p>
)}
</div>
</div>
{/* ── Anatomy ── */}
<div className="flex flex-col gap-4">
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
Anatomy
</h3>
<div className="grid grid-cols-2 gap-6">
{/* 구조 목업 */}
<div
className="rounded-lg border border-solid p-4 flex flex-col gap-3"
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
<span
className="font-mono text-caption uppercase"
style={{ letterSpacing: '1px', color: t.textMuted }}
>
Structure
</span>
<div
className="rounded relative flex items-end justify-end"
style={{
backgroundColor: isDark ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.04)',
padding: '16px',
minHeight: '160px',
}}
>
{/* 성공 Toast 목업 */}
<div className="flex flex-col gap-1.5 w-full max-w-[220px]">
{(['success', 'info', 'error'] as ToastType[]).map((type, i) => {
const cfg = TOAST_CONFIG[type];
return (
<div
key={type}
className="rounded border border-solid flex items-center gap-2 px-3 py-2"
style={{
backgroundColor: toastBg,
borderColor: toastBorder,
borderLeft: `3px solid ${cfg.color}`,
opacity: 1 - i * 0.2,
}}
>
<span
className="font-mono text-caption shrink-0"
style={{ color: cfg.color }}
>
{cfg.icon}
</span>
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
{DEMO_MESSAGES[type].slice(0, 14)}
</span>
</div>
);
})}
</div>
</div>
</div>
{/* 위치 규칙 */}
<div
className="rounded-lg border border-solid p-4 flex flex-col gap-3"
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
<span
className="font-mono text-caption uppercase"
style={{ letterSpacing: '1px', color: t.textMuted }}
>
Position Rules
</span>
<div className="flex flex-col gap-2">
{[
{ label: 'position', value: 'fixed', desc: '뷰포트 기준 고정' },
{ label: 'bottom', value: '24px', desc: '화면 하단에서 24px' },
{ label: 'right', value: '24px', desc: '화면 우측에서 24px' },
{ label: 'z-index', value: 'z-60', desc: '콘텐츠 위, Modal(9999) 아래' },
{ label: 'width', value: '320px (고정)', desc: '일정한 너비 유지' },
{ label: 'gap', value: '8px (스택)', desc: '복수 Toast 간격' },
].map((item) => (
<div key={item.label} className="flex items-center gap-2">
<span
className="font-mono text-caption rounded px-1.5 py-0.5 shrink-0 w-24 text-right"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
color: t.textAccent,
}}
>
{item.label}
</span>
<span className="font-mono text-caption" style={{ color: t.textPrimary }}>
{item.value}
</span>
<span className="font-korean text-caption ml-auto" style={{ color: t.textMuted }}>
{item.desc}
</span>
</div>
))}
</div>
</div>
</div>
</div>
{/* ── 타입별 색상 ── */}
<div className="flex flex-col gap-4">
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
</h3>
<div className="grid grid-cols-4 gap-3">
{(Object.entries(TOAST_CONFIG) as [ToastType, (typeof TOAST_CONFIG)[ToastType]][]).map(
([type, cfg]) => (
<div
key={type}
className="rounded-lg border border-solid p-4 flex flex-col gap-3"
style={{
backgroundColor: t.cardBg,
borderColor: t.cardBorder,
borderLeft: `3px solid ${cfg.color}`,
}}
>
<div className="flex items-center gap-2">
<span className="font-mono text-lg" style={{ color: cfg.color }}>
{cfg.icon}
</span>
<span className="font-mono text-sm font-bold" style={{ color: cfg.color }}>
{cfg.label}
</span>
</div>
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
{cfg.color}
</span>
<span
className="font-korean text-caption leading-5"
style={{ color: t.textSecondary }}
>
{type === 'success' && '저장 완료, 복사 완료, 전송 성공'}
{type === 'error' && 'API 오류, 저장 실패, 권한 없음'}
{type === 'info' && '작업 시작, 업데이트 알림'}
{type === 'warning' && '미저장 변경, 만료 임박'}
</span>
</div>
),
)}
</div>
</div>
{/* ── 구현 패턴 제안 ── */}
<div className="flex flex-col gap-4">
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
useToast Hook
</h3>
<div
className="rounded-lg border border-solid p-5 flex flex-col gap-4"
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
Toast는 <strong>Zustand store + useToast hook</strong>{' '}
. ToastContainer는 App.tsx .
</p>
<div className="flex flex-col gap-3">
{[
{
title: 'toastStore.ts',
code: 'const useToastStore = create<ToastStore>()\naddToast(type, message, duration?)\nremoveToast(id)',
desc: 'Zustand store — Toast 큐 관리',
},
{
title: 'useToast.ts',
code: 'const { success, error, info, warning } = useToast()\nsuccess("저장 완료") // duration 기본값 3000ms',
desc: '컴포넌트에서 호출하는 hook',
},
{
title: 'ToastContainer.tsx',
code: '<div className="fixed bottom-6 right-6 z-[60] flex flex-col gap-2">\n {toasts.map(t => <ToastItem key={t.id} {...t} />)}\n</div>',
desc: 'App.tsx 최상위에 배치',
},
].map((item) => (
<div
key={item.title}
className="rounded border border-solid p-3 flex flex-col gap-1.5"
style={{ borderColor: t.cardBorder }}
>
<div className="flex items-center justify-between">
<span
className="font-mono text-caption font-bold"
style={{ color: t.textPrimary }}
>
{item.title}
</span>
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
{item.desc}
</span>
</div>
<pre
className="font-mono text-caption leading-5 rounded p-2 overflow-x-auto"
style={{
backgroundColor: isDark ? 'rgba(0,0,0,0.30)' : 'rgba(0,0,0,0.04)',
color: t.textSecondary,
}}
>
{item.code}
</pre>
</div>
))}
</div>
</div>
</div>
{/* ── 실제 Toast 렌더링 (fixed 위치) ── */}
{toasts.length > 0 && (
<div
className="fixed bottom-6 right-6 z-[60] flex flex-col gap-2"
style={{ width: '300px' }}
>
{toasts.map((toast) => {
const cfg = TOAST_CONFIG[toast.type];
return (
<div
key={toast.id}
className="rounded border border-solid flex flex-col overflow-hidden"
style={{
backgroundColor: toastBg,
borderColor: toastBorder,
borderLeft: `3px solid ${cfg.color}`,
boxShadow: '0 4px 16px rgba(0,0,0,0.30)',
}}
>
<div className="flex items-center gap-2.5 px-3 py-2.5">
<span className="font-mono text-base shrink-0" style={{ color: cfg.color }}>
{cfg.icon}
</span>
<span className="font-korean text-sm flex-1" style={{ color: t.textPrimary }}>
{toast.message}
</span>
<button
type="button"
onClick={() => removeToast(toast.id)}
className="w-5 h-5 rounded flex items-center justify-center shrink-0 hover:opacity-70 transition-opacity"
style={{ color: t.textMuted }}
>
<span className="font-mono text-caption"></span>
</button>
</div>
{/* Progress bar */}
<div
style={{
height: '2px',
backgroundColor: isDark ? 'rgba(66,71,84,0.30)' : '#f1f5f9',
}}
>
<div
style={{
height: '2px',
width: `${toast.progress}%`,
backgroundColor: cfg.color,
transition: 'width 0.1s linear',
}}
/>
</div>
</div>
);
})}
</div>
)}
</div>
);
};
export default FloatToastContent;