/* ════════════════════════════════════════════════ Dashboard — komponen UI bersama ════════════════════════════════════════════════ */ /* Toast notifikasi global */ function useToast() { const [toast, setToast] = useState(null); const show = (msg) => { setToast(msg); setTimeout(() => setToast(null), 2200); }; const node = toast ? (
{toast}
) : null; return [node, show]; } /* Kartu panel */ function DCard({ children, pad = '22px', style }) { return
{children}
; } /* Tombol dashboard */ function DBtn({ children, onClick, variant = 'default', size = 'md', disabled, type = 'button', style }) { const pads = { sm: '7px 12px', md: '10px 18px', lg: '13px 24px' }; const fs = { sm: 12.5, md: 14, lg: 15 }; const variants = { default: { background: 'var(--surface)', color: 'var(--ink-deep)', border: '1.5px solid var(--border)' }, primary: { background: 'var(--navy)', color: '#fff', border: '1.5px solid var(--navy)' }, orange: { background: 'var(--orange)', color: '#fff', border: '1.5px solid var(--orange)' }, success: { background: 'var(--sage)', color: '#fff', border: '1.5px solid var(--sage)' }, danger: { background: '#fff', color: '#DC2626', border: '1.5px solid #FECACA' }, ghost: { background: 'transparent', color: 'var(--muted)', border: '1.5px solid transparent' }, }; return ( ); } /* Modal */ function Modal({ open, onClose, title, sub, children, width = '520px' }) { if (!open) return null; return (
e.stopPropagation()} style={{ background: 'var(--surface)', borderRadius: 18, width: '100%', maxWidth: width, boxShadow: '0 30px 80px rgba(0,0,0,.35)', marginTop: 'auto', marginBottom: 'auto' }}>
{title}
{sub &&
{sub}
}
{children}
); } /* Field input dashboard */ function DField({ label, children, hint }) { return (
{label && } {children} {hint &&
{hint}
}
); } const dInp = { width: '100%', padding: '11px 14px', borderRadius: 10, border: '1.5px solid var(--border)', fontSize: 14, fontFamily: 'inherit', background: 'var(--surface)', color: 'var(--ink-deep)', boxSizing: 'border-box' }; /* Tabel sederhana */ function DTable({ cols, children }) { return (
{cols.map((c, i) => )} {children}
{c}
); } function DTd({ children, style }) { return {children}; } /* Badge status */ function Pill({ children, color = 'gray' }) { const c = { green: ['rgba(31,107,71,.12)', '#1F6B47'], orange: ['rgba(232,145,42,.14)', '#C26F12'], blue: ['rgba(27,47,110,.12)', '#1B2F6E'], red: ['rgba(220,38,38,.1)', '#DC2626'], gray: ['var(--bg2)', 'var(--muted)'], }[color]; return {children}; } /* Kartu statistik overview */ function StatCard({ icon, num, label, accent }) { return (
{icon}
{num}
{label}
); } /* Modal crop gambar — pan + zoom sesuai rasio target */ function CropModal({ src, ratio, onCancel, onDone }) { const frameRef = React.useRef(); const imgRef = React.useRef(); const [img, setImg] = useState(null); const [zoom, setZoom] = useState(1); const [pos, setPos] = useState({ x: 0, y: 0 }); const drag = React.useRef(null); const [rw, rh] = (ratio || '16/9').split('/').map(Number); const frameW = () => frameRef.current ? frameRef.current.clientWidth : 320; const frameH = () => frameW() * rh / rw; useEffect(() => { const im = new Image(); im.onload = () => { setImg(im); setZoom(1); center(im, 1); }; im.src = src; }, [src]); const coverScale = (im) => Math.max(frameW() / im.width, frameH() / im.height); const dispSize = (im, z) => { const s = coverScale(im) * z; return { w: im.width * s, h: im.height * s, s }; }; const center = (im, z) => { const d = dispSize(im, z); setPos({ x: (frameW() - d.w) / 2, y: (frameH() - d.h) / 2 }); }; const clamp = (p, im, z) => { const d = dispSize(im, z); return { x: Math.min(0, Math.max(frameW() - d.w, p.x)), y: Math.min(0, Math.max(frameH() - d.h, p.y)) }; }; const onZoom = (z) => { setZoom(z); if (img) setPos(p => clamp({ x: p.x - (dispSize(img, z).w - dispSize(img, zoom).w) / 2, y: p.y - (dispSize(img, z).h - dispSize(img, zoom).h) / 2 }, img, z)); }; const start = (e) => { const pt = e.touches ? e.touches[0] : e; drag.current = { px: pt.clientX, py: pt.clientY, ox: pos.x, oy: pos.y }; }; const move = (e) => { if (!drag.current || !img) return; const pt = e.touches ? e.touches[0] : e; setPos(clamp({ x: drag.current.ox + (pt.clientX - drag.current.px), y: drag.current.oy + (pt.clientY - drag.current.py) }, img, zoom)); }; const end = () => { drag.current = null; }; const save = () => { if (!img) return; const d = dispSize(img, zoom); const OW = Math.min(1400, Math.round(frameW() * 2)), OH = Math.round(OW * rh / rw); const cv = document.createElement('canvas'); cv.width = OW; cv.height = OH; const ctx = cv.getContext('2d'); const srcX = (-pos.x) / d.s, srcY = (-pos.y) / d.s, srcW = frameW() / d.s, srcH = frameH() / d.s; ctx.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, OW, OH); cv.toBlob(b => onDone(b, cv.toDataURL('image/jpeg', 0.9)), 'image/jpeg', 0.9); }; return (

Geser untuk memposisikan, gunakan slider untuk memperbesar. Area dalam bingkai yang akan disimpan.

{img && }
🔍 onZoom(parseFloat(e.target.value))} style={{ flex: 1, accentColor: 'var(--sage)' }} />
✓ Simpan Gambar Batal
); } /* Upload gambar (mock-aware) + crop */ function ImageUpload({ value, onChange, label = 'Gambar', ratio = '16/9' }) { const ref = React.useRef(); const [busy, setBusy] = useState(false); const [cropSrc, setCropSrc] = useState(null); const pick = (e) => { const file = e.target.files[0]; if (!file) return; setCropSrc(URL.createObjectURL(file)); e.target.value = ''; }; const uploadBlob = async (blob, dataUrl) => { onChange(dataUrl); setBusy(true); setCropSrc(null); try { const fd = new FormData(); fd.append('file', blob, 'crop.jpg'); fd.append('_preview', dataUrl); const res = await api.post('upload.php', fd); if (res.ok) onChange(res.data?.url || res.url || dataUrl); } catch {} finally { setBusy(false); } }; return (
ref.current?.click()} style={{ aspectRatio: ratio, borderRadius: 12, border: '2px dashed var(--border)', background: 'var(--bg2)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', overflow: 'hidden', position: 'relative' }}> {value ? : {busy ? 'Mengunggah…' : '📷 Klik untuk unggah'}} {value && !busy && Ganti / Crop}
{cropSrc && setCropSrc(null)} onDone={uploadBlob} />}
); } Object.assign(window, { useToast, DCard, DBtn, Modal, DField, dInp, DTable, DTd, Pill, StatCard, ImageUpload, CropModal });