/* ============================================================ Shared UI primitives + icon set (exported to window) ============================================================ */ const { useState, useEffect, useRef, useCallback, createContext, useContext } = React; /* ---------------- Icons (stroke, currentColor) ---------------- */ function Ic({ d, fill, vb = 24, sw = 1.8, children, ...p }) { return ( {children || } ); } const Icon = { home: (p) => , grid: (p) => , calendar: (p) => , flow: (p) => , list: (p) => , layers: (p) => , users: (p) => , shield: (p) => , clock: (p) => , plus: (p) => , check: (p) => , checkCircle: (p) => , x: (p) => , chevR: (p) => , chevD: (p) => , chevL: (p) => , arrowR: (p) => , dots: (p) => , edit: (p) => , pause: (p) => , play: (p) => , trash: (p) => , search: (p) => , logout: (p) => , user: (p) => , doc: (p) => , inbox: (p) => , alert: (p) => , spark: (p) => , target: (p) => , menu: (p) => , send: (p) => , filter: (p) => , }; /* ---------------- Avatar ---------------- */ function Avatar({ name, color = '#C8102E', initial, size = 34, square }) { return ( {initial || (name || '?')[0]} ); } /* ---------------- Status pill / role badge ---------------- */ const STATUS_LABEL = { not_started: '시작 전', active: '진행 중', done: '완료' }; function StatusPill({ status }) { return {STATUS_LABEL[status] || status}; } function RoleBadge({ role }) { const cls = role === 'ADMIN' ? 'rb-admin' : role === 'CNX' ? 'rb-cnx' : role === 'PUB' ? 'rb-pub' : 'rb-lge'; return {role === 'PUB' ? '발행' : role}; } /* ---------------- Button ---------------- */ function Btn({ variant = 'ghost', size, icon, children, className = '', ...p }) { const sz = size === 'sm' ? 'btn-sm' : size === 'lg' ? 'btn-lg' : ''; return ( ); } /* ---------------- Dropdown menu ---------------- */ function Menu({ trigger, children, align = 'right' }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { if (!open) return; const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h); }, [open]); return ( { e.stopPropagation(); setOpen((o) => !o); }}>{trigger} {open &&
{ e.stopPropagation(); setOpen(false); }}>{children}
}
); } /* ---------------- Modal ---------------- */ function Modal({ title, sub, onClose, children, footer, wide }) { useEffect(() => { const h = (e) => e.key === 'Escape' && onClose && onClose(); document.addEventListener('keydown', h); return () => document.removeEventListener('keydown', h); }, [onClose]); return (
e.target === e.currentTarget && onClose && onClose()}>
{title && (

{title}

{sub &&

{sub}

}
{onClose && }
)}
{children}
{footer &&
{footer}
}
); } /* ---------------- Drawer ---------------- */ function Drawer({ onClose, head, children, footer }) { useEffect(() => { const h = (e) => e.key === 'Escape' && onClose && onClose(); document.addEventListener('keydown', h); return () => document.removeEventListener('keydown', h); }, [onClose]); return (
e.target === e.currentTarget && onClose && onClose()}>
{head}
{children}
{footer &&
{footer}
}
); } /* ---------------- Toast ---------------- */ const ToastCtx = createContext(null); function ToastProvider({ children }) { const [toasts, setToasts] = useState([]); const push = useCallback((msg, kind = 'ok') => { const id = Math.random().toString(36).slice(2); setToasts((t) => [...t, { id, msg, kind }]); setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 3200); }, []); return ( {children}
{toasts.map((t) => (
{t.kind === 'ok' ? : } {t.msg}
))}
); } const useToast = () => useContext(ToastCtx); /* ---------------- Skeleton helpers ---------------- */ function Skel({ w, h = 14, r = 6, style }) { return ; } function CardSkel() { return (
); } /* ---------------- Empty / Error ---------------- */ function EmptyState({ icon, title, desc, action }) { return (
{icon || }

{title}

{desc &&

{desc}

}{action}
); } function ErrorState({ onRetry, msg }) { return (

불러오지 못했습니다

{msg || '데이터를 가져오는 중 문제가 발생했습니다.'}

{onRetry && 다시 시도}
); } /* ---------------- async data hook ---------------- */ function useAsync(fn, deps) { const [state, setState] = useState({ loading: true, error: null, data: null }); const run = useCallback(() => { setState({ loading: true, error: null, data: null }); fn().then((data) => setState({ loading: false, error: null, data })) .catch((e) => setState({ loading: false, error: e.message || '오류', data: null })); }, deps || []); useEffect(() => { run(); }, [run]); return { ...state, reload: run, setData: (d) => setState((s) => ({ ...s, data: typeof d === 'function' ? d(s.data) : d })) }; } /* ---------------- progress ring ---------------- */ function Ring({ value, total, size = 44, stroke = 4, color }) { const pct = total ? Math.min(value / total, 1) : 0; const r = (size - stroke) / 2, c = 2 * Math.PI * r; const col = color || (value === total && total ? 'var(--pub-green)' : value > total ? 'var(--lg-red)' : 'var(--amber)'); return ( {value}/{total} ); } /* ---------------- date helpers ---------------- */ const fmtDate = (iso) => { const d = new Date(iso); return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`; }; const fmtDateTime = (iso) => { const d = new Date(iso); return `${fmtDate(iso)} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; }; const fmtMD = (iso) => { const d = new Date(iso); return `${d.getMonth() + 1}/${d.getDate()}`; }; Object.assign(window, { Icon, Avatar, StatusPill, RoleBadge, Btn, Menu, Modal, Drawer, ToastProvider, useToast, Skel, CardSkel, EmptyState, ErrorState, useAsync, Ring, fmtDate, fmtDateTime, fmtMD, STATUS_LABEL, });