/* ============================================================
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 (
);
}
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 && (
)}
{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,
});