/* ============================================================
KOMPONEN INTI — Interactive CV
PhotoSlot, Panel info, 3 konsep interaksi, render konten.
============================================================ */
const { useState, useEffect, useRef, useCallback } = React;
/* ---------- Foto interaktif (drag & drop, tersimpan) ---------- */
/* ---------- Foto profil (terkunci — efek hover tetap, tapi tidak bisa diganti) ---------- */
function PhotoSlot({ accent, styleMode, defaultSrc }) {
const [hover, setHover] = useState(false);
const [intro, setIntro] = useState(true);
const shown = defaultSrc || null;
useEffect(() => {
const id = setTimeout(() => setIntro(false), 1050);
return () => clearTimeout(id);
}, []);
return (
setHover(true)}
onMouseLeave={() => setHover(false)}
>
{shown ? (
) : (
FOTO PORTRAIT
assets/images/profile/me.png
)}
{/* bingkai siku-siku — animasi buka dari tengah saat load */}
);
}
/* ---------- Teks latar bergerak (typewriter) ---------- */
function BigType({ words, motion }) {
const [txt, setTxt] = useState((words && words[0]) || "");
useEffect(() => {
if (!words || !words.length) return;
if (!motion) { setTxt(words[0]); return; }
let w = 0, c = 0, deleting = false, timer;
const tick = () => {
const word = words[w % words.length];
if (!deleting) {
c++; setTxt(word.slice(0, c));
if (c >= word.length) { deleting = true; timer = setTimeout(tick, 1500); return; }
timer = setTimeout(tick, 95);
} else {
c--; setTxt(word.slice(0, Math.max(0, c)));
if (c <= 0) { deleting = false; w++; timer = setTimeout(tick, 300); return; }
timer = setTimeout(tick, 45);
}
};
timer = setTimeout(tick, 600);
return () => clearTimeout(timer);
}, [motion, words]);
return (
{txt}
);
}
/* ---------- Pager (tombol ‹ halaman › ) ---------- */
function Pager({ page, total, accent, onPrev, onNext }) {
if (total <= 1) return null;
return (
{page + 1} / {total}
);
}
/* ---------- Portofolio (paginasi otomatis; lebih banyak saat mode lebar) ---------- */
const SHORTS_PER_PAGE = 6; // mode normal: 3 kolom × 2 baris
const SHORTS_PER_PAGE_WIDE = 10; // mode lebar: 5 kolom × 2 baris
const FEAT_PER_PAGE = 1; // mode normal
const FEAT_PER_PAGE_WIDE = 2; // mode lebar: 2 berdampingan
function PortfolioContent({ s, accent, onPlayVideo, expanded }) {
const shorts = s.shorts || [];
const featured = s.featured || [];
const shPer = expanded ? SHORTS_PER_PAGE_WIDE : SHORTS_PER_PAGE;
const ftPer = expanded ? FEAT_PER_PAGE_WIDE : FEAT_PER_PAGE;
const [shPage, setShPage] = useState(0);
const [ftPage, setFtPage] = useState(0);
const shTotal = Math.max(1, Math.ceil(shorts.length / shPer));
const ftTotal = Math.max(1, Math.ceil(featured.length / ftPer));
// Jaga halaman tetap valid saat jumlah per-halaman berubah (toggle lebar).
const shCur = Math.min(shPage, shTotal - 1);
const ftCur = Math.min(ftPage, ftTotal - 1);
const shStart = shCur * shPer;
const ftStart = ftCur * ftPer;
const shView = shorts.slice(shStart, shStart + shPer);
const ftView = featured.slice(ftStart, ftStart + ftPer);
return (
{s.body}
{shorts.length > 0 && (
Shorts
setShPage((p) => (Math.min(p, shTotal - 1) - 1 + shTotal) % shTotal)}
onNext={() => setShPage((p) => (Math.min(p, shTotal - 1) + 1) % shTotal)} />
{shView.map((v, i) => {
const gi = shStart + i;
return (
);
})}
)}
{featured.length > 0 && (
Landscape
setFtPage((p) => (Math.min(p, ftTotal - 1) - 1 + ftTotal) % ftTotal)}
onNext={() => setFtPage((p) => (Math.min(p, ftTotal - 1) + 1) % ftTotal)} />
{ftView.map((v, i) => {
const gi = ftStart + i;
return (
);
})}
)}
);
}
/* ---------- Tombol kontak 3D (klik = langsung buka) ----------
Ikon (ContactIcon) & iconKindFromType berasal dari cv-icons.jsx. */
function ContactButtons({ channels, accent }) {
const list = channels || [];
// Jenis ikon: pakai pilihan eksplisit (c.icon) jika ada, kalau tidak tebak dari teks.
const kindOf = (c) => (c.icon ? c.icon : iconKindFromType(c.type));
const urlFor = (c, kind) => {
if (c.href) return c.href;
const v = String(c.value || "").replace(/^@/, "").trim();
if (kind === "instagram") return "https://instagram.com/" + v;
if (kind === "tiktok") return "https://www.tiktok.com/@" + v;
if (kind === "youtube") return "https://youtube.com/@" + v;
if (kind === "facebook") return "https://facebook.com/" + v;
if (kind === "x") return "https://x.com/" + v;
if (kind === "linkedin") return "https://www.linkedin.com/in/" + v;
if (kind === "telegram") return "https://t.me/" + v;
if (kind === "whatsapp") return "https://wa.me/" + v.replace(/[^0-9]/g, "");
if (kind === "email") return "mailto:" + String(c.value || "").trim();
if (kind === "website") {
const raw = String(c.value || "").trim();
return /^https?:\/\//i.test(raw) ? raw : "https://" + raw;
}
return "";
};
const onClick = (c, kind) => {
const url = urlFor(c, kind);
if (!url) return;
if (kind === "email") { window.location.href = url; return; }
window.open(url, "_blank", "noopener,noreferrer");
};
return (
{list.map((c, i) => {
const kind = kindOf(c);
return (
);
})}
);
}
/* ---------- Konten tiap seksi ---------- */
function SectionContent({ id, data, accent, onPlayVideo, expanded }) {
const s = data.sections[id];
if (!s) return null;
if (id === "tentang") {
return (
{s.body}
- Peran{data.role}
- Lokasi{data.location}
);
}
if (id === "pengalaman") {
return (
{s.items.map((it, i) => (
-
{it.period}
{it.current && sekarang}
{it.company}
{it.role}
{it.note}
))}
);
}
if (id === "pendidikan") {
return (
{s.items.map((it, i) => (
{it.period}
{it.school}
{it.degree}
{it.note}
))}
);
}
if (id === "keahlian") {
return (
{s.items.map((it, i) => (
{String(i + 1).padStart(2, "0")}
{it.name}
{it.note}
))}
);
}
if (id === "portofolio") {
return ;
}
if (id === "kontak") {
return (
);
}
return null;
}
/* ---------- Panel info (sheet melayang) ---------- */
function InfoPanel({ id, data, accent, onClose, onPlayVideo, expanded, onToggleExpand }) {
const open = !!id;
const s = id ? data.sections[id] : null;
const canExpand = id === "portofolio";
const wide = canExpand && expanded;
// Mundur selangkah: kalau sedang full screen → kembali ke mode biasa dulu;
// kalau sudah biasa → baru tutup panel.
const dismiss = () => { if (wide) onToggleExpand(); else onClose(); };
useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") dismiss(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [wide, onToggleExpand, onClose]);
return (
);
}
/* ---------- Unduh CV + preview saat hover ---------- */
function CvDownload({ accent, url, defaultPreview }) {
const PKEY = "ian_cv_preview";
const [preview, setPreview] = useState(null);
const inputRef = useRef(null);
const shownPrev = preview || defaultPreview || null;
useEffect(() => {
const load = () => { try { setPreview(localStorage.getItem(PKEY) || null); } catch (e) {} };
load();
const onPick = () => inputRef.current && inputRef.current.click();
const onClear = () => { try { localStorage.removeItem(PKEY); } catch (e) {} setPreview(null); };
window.addEventListener("cv-pick-preview", onPick);
window.addEventListener("cv-clear-preview", onClear);
return () => { window.removeEventListener("cv-pick-preview", onPick);
window.removeEventListener("cv-clear-preview", onClear); };
}, []);
const handleFile = (file) => {
if (!file || !file.type.startsWith("image/")) return;
const r = new FileReader();
r.onload = (e) => { const d = e.target.result; setPreview(d);
try { localStorage.setItem(PKEY, d); } catch (err) {} };
r.readAsDataURL(file);
};
return (
);
}
/* ---------- Lightbox player (YouTube Shorts 9:16) ---------- */
function VideoLightbox({ state, videos, land, accent, onClose, onNav }) {
useEffect(() => {
const onKey = (e) => {
if (e.key === "Escape") onClose();
else if (e.key === "ArrowRight") onNav(1);
else if (e.key === "ArrowLeft") onNav(-1);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose, onNav]);
const open = state != null;
const v = open ? videos[state] : null;
return (
{v && (
{v.title}
{state + 1} / {videos.length}
)}
);
}
/* ---------- Video background (drag & drop, tersimpan via IndexedDB) ---------- */
const VID_DB = "ian_cv_db", VID_STORE = "media", VID_KEY = "bgVideo";
function vidOpen() {
return new Promise((res, rej) => {
const r = indexedDB.open(VID_DB, 1);
r.onupgradeneeded = () => r.result.createObjectStore(VID_STORE);
r.onsuccess = () => res(r.result);
r.onerror = () => rej(r.error);
});
}
async function vidSave(blob) {
const db = await vidOpen();
return new Promise((res, rej) => {
const tx = db.transaction(VID_STORE, "readwrite");
tx.objectStore(VID_STORE).put(blob, VID_KEY);
tx.oncomplete = () => res();
tx.onerror = () => rej(tx.error);
});
}
async function vidLoad() {
const db = await vidOpen();
return new Promise((res) => {
const tx = db.transaction(VID_STORE, "readonly");
const g = tx.objectStore(VID_STORE).get(VID_KEY);
g.onsuccess = () => res(g.result || null);
g.onerror = () => res(null);
});
}
function VideoBg({ enabled, darkness, autoData }) {
const [url, setUrl] = useState(null);
const [drag, setDrag] = useState(false);
const [suppressed, setSuppressed] = useState(false); // dimatikan otomatis krn koneksi
const inputRef = useRef(null);
const vidRef = useRef(null);
const stallTimer = useRef(null);
useEffect(() => {
let live = true, objUrl;
vidLoad().then((blob) => {
if (live && blob) { objUrl = URL.createObjectURL(blob); setUrl(objUrl); }
});
return () => { live = false; if (objUrl) URL.revokeObjectURL(objUrl); };
}, []);
const handleFile = useCallback(async (file) => {
if (!file || !file.type.startsWith("video/")) return;
await vidSave(file);
setUrl((old) => { if (old) URL.revokeObjectURL(old); return URL.createObjectURL(file); });
}, []);
useEffect(() => {
const onPick = () => inputRef.current && inputRef.current.click();
const onClear = async () => {
try { const db = await vidOpen(); const tx = db.transaction(VID_STORE, "readwrite");
tx.objectStore(VID_STORE).delete(VID_KEY); } catch (e) {}
setUrl((old) => { if (old) URL.revokeObjectURL(old); return null; });
};
window.addEventListener("cv-pick-video", onPick);
window.addEventListener("cv-clear-video", onClear);
return () => { window.removeEventListener("cv-pick-video", onPick);
window.removeEventListener("cv-clear-video", onClear); };
}, []);
// Auto-deteksi koneksi: matikan video saat hemat-data / koneksi lambat.
useEffect(() => {
if (!autoData) { setSuppressed(false); return; }
const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
const evalConn = () => {
if (!conn) { setSuppressed(false); return; }
const slow = conn.saveData === true ||
["slow-2g", "2g", "3g"].includes(conn.effectiveType);
setSuppressed(slow);
};
evalConn();
if (conn && conn.addEventListener) {
conn.addEventListener("change", evalConn);
return () => conn.removeEventListener("change", evalConn);
}
}, [autoData]);
// Hemat baterai/CPU: jeda video saat tab tidak aktif.
useEffect(() => {
const v = vidRef.current;
if (!v) return;
const onVis = () => { if (document.hidden) v.pause(); else v.play().catch(() => {}); };
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
}, [url, suppressed]);
// Fallback saat buffering lama (>6s) → anggap koneksi tersendat.
const onWaiting = () => {
if (!autoData) return;
clearTimeout(stallTimer.current);
stallTimer.current = setTimeout(() => setSuppressed(true), 6000);
};
const onPlaying = () => { clearTimeout(stallTimer.current); };
if (!enabled) return null;
const dk = darkness == null ? 0.62 : darkness;
const showVideo = url && !suppressed;
return (
{ e.preventDefault(); setDrag(true); }}
onDragLeave={() => setDrag(false)}
onDrop={(e) => { e.preventDefault(); setDrag(false); handleFile(e.dataTransfer.files[0]); }}
>
{showVideo ? (
) : !url ? (
) : null}
handleFile(e.target.files[0])} />
{url &&
}
{url && suppressed && (
Video dijeda otomatis — koneksi terbatas / hemat data
)}
);
}
/* ---------- Tanda tangan (SVG stroke-draw, mengikuti goresan) ---------- */
function Signature({ name, revealed }) {
const ref = useRef(null);
const [len, setLen] = useState(1000);
useEffect(() => {
const measure = () => {
if (!ref.current) return;
try {
const l = ref.current.getComputedTextLength() * 2.7;
if (l && isFinite(l)) setLen(l);
} catch (e) {}
};
measure();
if (document.fonts && document.fonts.ready) document.fonts.ready.then(measure);
}, [name]);
return (
);
}
/* ---------- Jabatan berganti (konveyor ke atas, tanpa jeda) ---------- */
function RotatingRole({ words, motion }) {
const [i, setI] = useState(0);
const [shift, setShift] = useState(false);
useEffect(() => {
if (!motion || !words || words.length < 2) return;
const id = setInterval(() => setShift(true), 2000);
return () => clearInterval(id);
}, [motion, words]);
if (!words || !words.length) return null;
const next = (i + 1) % words.length;
const onEnd = () => { setI(next); setShift(false); };
return (
{words[i]}
{words[next]}
);
}
/* ---------- Ikon SVG per bagian (ikut currentColor) ---------- */
const ICON_PATHS = {
// Tentang Saya — sosok orang
tentang: ,
// Pengalaman — koper kerja
pengalaman: ,
// Pendidikan — topi wisuda
pendidikan: ,
// Keahlian — sparkle (kreativitas / craft)
keahlian: ,
// Kontak — balon chat
kontak: ,
// Portofolio — layar play
portofolio: ,
};
function SecIcon({ id }) {
return (
);
}
/* ---------- Konsep 3: Menu melayang ---------- */
function calcAge(iso) {
const b = new Date(iso);
const n = new Date();
let a = n.getFullYear() - b.getFullYear();
const m = n.getMonth() - b.getMonth();
if (m < 0 || (m === 0 && n.getDate() < b.getDate())) a--;
return a;
}
function FloatingMenu({ data, accent, active, onPick }) {
return (
);
}
Object.assign(window, {
PhotoSlot, SectionContent, InfoPanel, BigType, SecIcon, RotatingRole, VideoBg, VideoLightbox, CvDownload,
FloatingMenu,
});