5 Commits

10 changed files with 251 additions and 137 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+13 -13
View File
@@ -1,20 +1,20 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="id"> <html lang="id">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ACAAB — Dev Preview</title> <title>ACAAB — Dev Preview</title>
<style> <style>
#dev-banner { #dev-banner {
background: #feb401; color: #003150; font-size: 11px; font-weight: 700; background: #feb401; color: #003150; font-size: 11px; font-weight: 700;
text-align: center; padding: 6px; letter-spacing: .05em; position: sticky; top: 0; z-index: 999; text-align: center; padding: 6px; letter-spacing: .05em; position: sticky; top: 0; z-index: 999;
} }
</style> </style>
<script type="module" crossorigin src="../acaab.js"></script> <script type="module" crossorigin src="../acaab.js"></script>
<link rel="stylesheet" crossorigin href="../acaab.css"> <link rel="stylesheet" crossorigin href="../acaab.css">
</head> </head>
<body> <body>
<div id="dev-banner">⚡ DEV MODE — Navbar &amp; footer tidak tampil di sini. Gunakan kit dev server (root) untuk preview lengkap.</div> <div id="dev-banner">⚡ DEV MODE — Navbar &amp; footer tidak tampil di sini. Gunakan kit dev server (root) untuk preview lengkap.</div>
<div id="root"></div> <div id="root"></div>
</body>
</html> </body>
@@ -88,10 +88,30 @@ export default function AchievementModal({ achievement, onClose }: AchievementMo
<InfoRow icon={<Tag size={14} />} label={t.modal.bidang} value={achievement.category} /> <InfoRow icon={<Tag size={14} />} label={t.modal.bidang} value={achievement.category} />
</div> </div>
{/* Members list */}
{achievement.members && achievement.members.length > 0 && (
<div className="border-t border-slate-100 pt-4 mb-4">
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-2">Anggota Tim</p>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{achievement.members.map((m, i) => (
<li key={i} className="flex items-center gap-2 bg-slate-50 px-3 py-2 rounded-md">
<span className="w-6 h-6 rounded-full bg-primary-gold/20 text-primary-gold flex items-center justify-center text-xs font-bold shrink-0">
{m.nama.charAt(0).toUpperCase()}
</span>
<div className="flex flex-col">
<span className="text-sm font-semibold text-slate-700 leading-none">{m.nama}</span>
{m.angkatan && <span className="text-[10px] text-slate-500 mt-1">Angkatan {m.angkatan}</span>}
</div>
</li>
))}
</ul>
</div>
)}
{/* Description */} {/* Description */}
<div className="border-t border-slate-100 pt-4"> <div className="border-t border-slate-100 pt-4">
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-2">{t.modal.deskripsi}</p> <p className="text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-2">{t.modal.deskripsi}</p>
<p className="text-slate-600 text-sm leading-relaxed">{achievement.description}</p> <p className="text-slate-600 text-sm leading-relaxed">{achievement.description.replace(/\*\*/g, '')}</p>
</div> </div>
{/* Close button */} {/* Close button */}
@@ -23,7 +23,7 @@ export default function ProjectCarousel() {
const [filterYear, setFilterYear] = useState(''); const [filterYear, setFilterYear] = useState('');
const categories = useMemo( const categories = useMemo(
() => [...new Set(projects.map((p) => p.category))].sort(), () => [...new Set(projects.flatMap((p) => p.category))].sort(),
[projects] [projects]
); );
@@ -40,9 +40,9 @@ export default function ProjectCarousel() {
!q || !q ||
p.title.toLowerCase().includes(q) || p.title.toLowerCase().includes(q) ||
p.studentName.toLowerCase().includes(q) || p.studentName.toLowerCase().includes(q) ||
p.category.toLowerCase().includes(q) || p.category.join(' ').toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q); p.description.toLowerCase().includes(q);
const matchCategory = !filterCategory || p.category === filterCategory; const matchCategory = !filterCategory || p.category.includes(filterCategory);
const matchYear = !filterYear || String(p.year) === filterYear; const matchYear = !filterYear || String(p.year) === filterYear;
return matchSearch && matchCategory && matchYear; return matchSearch && matchCategory && matchYear;
}); });
@@ -271,11 +271,15 @@ export default function ProjectCarousel() {
<h4 className="text-lg font-bold font-display text-white leading-snug mb-1"> <h4 className="text-lg font-bold font-display text-white leading-snug mb-1">
{project.title} {project.title}
</h4> </h4>
{project.category && ( {project.category && project.category.length > 0 && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-primary-gold uppercase tracking-wider"> <div className="flex flex-wrap gap-2 mt-1">
<Tag size={10} /> {project.category.map((cat, i) => (
{project.category} <span key={i} className="inline-flex items-center gap-1 text-[10px] font-bold text-primary-gold uppercase tracking-wider bg-black/30 px-2 py-1 rounded backdrop-blur-sm">
</span> <Tag size={10} />
{cat}
</span>
))}
</div>
)} )}
</div> </div>
</div> </div>
+36 -8
View File
@@ -56,12 +56,14 @@ export default function ProjectModal({ project, onClose }: ProjectModalProps) {
</button> </button>
{/* Category badge */} {/* Category badge */}
{project.category && ( {project.category && project.category.length > 0 && (
<div className="absolute bottom-4 left-4"> <div className="absolute bottom-4 left-4 flex flex-wrap gap-2">
<span className="bg-primary-gold text-slate-900 text-xs font-bold px-3 py-1.5 rounded flex items-center gap-1.5 uppercase tracking-wide"> {project.category.map((cat, i) => (
<Tag size={12} /> <span key={i} className="bg-primary-gold text-slate-900 text-xs font-bold px-3 py-1.5 rounded flex items-center gap-1.5 uppercase tracking-wide">
{project.category} <Tag size={12} />
</span> {cat}
</span>
))}
</div> </div>
)} )}
</div> </div>
@@ -81,15 +83,41 @@ export default function ProjectModal({ project, onClose }: ProjectModalProps) {
{project.year && ( {project.year && (
<InfoRow icon={<Calendar size={14} />} label={t.modal.tahun} value={String(project.year)} /> <InfoRow icon={<Calendar size={14} />} label={t.modal.tahun} value={String(project.year)} />
)} )}
{project.category && ( {project.category && project.category.length > 0 && (
<InfoRow icon={<Tag size={14} />} label={t.modal.bidang} value={project.category} /> <InfoRow icon={<Tag size={14} />} label={t.modal.bidang} value={project.category.join(', ')} />
)} )}
</div> </div>
{/* Members list */}
{project.members && project.members.length > 0 && (
<div className="border-t border-slate-100 pt-4 mb-4">
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-2">Anggota Tim</p>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{project.members.map((m, i) => (
<li key={i} className="flex items-center gap-2 bg-slate-50 px-3 py-2 rounded-md border border-slate-100">
<span className="w-6 h-6 rounded-full bg-primary-gold/20 text-primary-gold flex items-center justify-center text-xs font-bold shrink-0">
{m.nama.charAt(0).toUpperCase()}
</span>
<div className="flex flex-col">
<span className="text-sm font-semibold text-slate-700 leading-none">{m.nama}</span>
{m.nim && <span className="text-[10px] text-slate-500 mt-1">{m.nim}</span>}
</div>
</li>
))}
</ul>
</div>
)}
{/* Description */} {/* Description */}
<div className="border-t border-slate-100 pt-4 mb-5"> <div className="border-t border-slate-100 pt-4 mb-5">
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-2">{t.modal.deskripsi}</p> <p className="text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-2">{t.modal.deskripsi}</p>
<p className="text-slate-600 text-sm leading-relaxed">{project.description}</p> <p className="text-slate-600 text-sm leading-relaxed">{project.description}</p>
{project.catatan && (
<div className="mt-4 p-3 bg-blue-50/50 border border-blue-100 rounded-lg">
<p className="text-[10px] font-bold uppercase tracking-wider text-blue-400 mb-1">Catatan Tambahan</p>
<p className="text-sm text-slate-600 italic">{project.catatan}</p>
</div>
)}
</div> </div>
{/* Project link */} {/* Project link */}
+55 -54
View File
@@ -55,47 +55,53 @@ function useCountUp(target: number, duration = 1600) {
return { count, elRef }; return { count, elRef };
} }
/* ── Big stat ──────────────────────────────────────────────────────────── */ /* ── Stat number with label ─────────────────────────────────────────────── */
function BigStat({ value, label }: { value: number; label: string }) { function StatItem({ value, label, delay = 0 }: { value: number; label: string; delay?: number }) {
const { count, elRef } = useCountUp(value);
return (
<div ref={elRef} className="flex flex-col items-center justify-center gap-1 md:gap-1.5 px-2 h-full">
<div className="text-[3.5rem] md:text-[4.5rem] lg:text-[5rem] font-black font-display text-primary-navy leading-none tabular-nums">
{count}
</div>
<div className="text-[10px] md:text-[11px] lg:text-[13px] leading-none font-extrabold uppercase tracking-[0.1em] text-primary-navy text-center">
{label}
</div>
</div>
);
}
/* ── Level row ──────────────────────────────────────────────────────────── */
function LevelRow({ label, value, delay = 0 }: { label: string; value: number; delay?: number }) {
const { count, elRef } = useCountUp(value, 1600 + delay); const { count, elRef } = useCountUp(value, 1600 + delay);
return ( return (
<div ref={elRef} className="flex items-center gap-2 md:gap-3 lg:gap-4"> <div ref={elRef} className="flex flex-col items-center gap-1" style={{ minWidth: 'clamp(5rem, 10vw, 8rem)' }}>
<span className="text-[10px] md:text-[11px] lg:text-[12px] leading-none font-extrabold uppercase tracking-[0.1em] text-primary-navy text-right"> <div
{label} className="font-display font-black text-primary-navy leading-none tabular-nums"
</span> style={{ fontSize: 'clamp(2.8rem, 6vw, 5rem)' }}
<span className="text-lg md:text-xl lg:text-2xl leading-none font-black font-display text-primary-navy tabular-nums text-right w-[1rem] md:w-[1.25rem] lg:w-[1.5rem]"> >
{count} {count}
</span> </div>
<div
className="font-display font-bold text-primary-navy text-center leading-tight uppercase tracking-wide"
style={{ fontSize: 'clamp(0.6rem, 1.1vw, 0.8rem)' }}
>
{label}
</div>
</div> </div>
); );
} }
/* ── Dividers ───────────────────────────────────────────────────────────── */ /* ── Section panel ──────────────────────────────────────────────────────── */
function VDivider() { function StatPanel({
title,
children,
flexClass = 'flex-1',
}: {
title: string;
children: React.ReactNode;
flexClass?: string;
}) {
return ( return (
<div className="hidden md:flex items-center justify-center self-stretch"> <div className={`flex flex-col gap-5 ${flexClass}`}>
<div className="w-px h-full bg-primary-navy/10 rounded-full" /> {/* Dark pill header — full width of this panel */}
<div
className="bg-primary-navy text-white font-display font-bold text-center rounded-xl px-8 py-3 w-full uppercase tracking-wide"
style={{ fontSize: 'clamp(0.85rem, 1.5vw, 1.05rem)' }}
>
{title}
</div>
{/* Numbers row — equal spacing */}
<div className="flex items-end justify-evenly pb-2">
{children}
</div>
</div> </div>
); );
} }
function HDivider() {
return <div className="block md:hidden h-px w-2/3 mx-auto bg-primary-navy/10 my-2 rounded-full" />;
}
/* ── Main export ────────────────────────────────────────────────────────── */ /* ── Main export ────────────────────────────────────────────────────────── */
export default function Stats() { export default function Stats() {
@@ -119,34 +125,29 @@ export default function Stats() {
<div id="tentang"> <div id="tentang">
{/* ── Stats bar ─────────────────────────────────────────────────── */} {/* ── Stats bar ─────────────────────────────────────────────────── */}
<section className="relative z-10 mb-2 rounded-xl overflow-hidden shadow-[0_10px_30px_-10px_rgba(253,184,19,0.4)]"> <section className="relative z-10 mb-2 rounded-xl overflow-hidden shadow-[0_10px_30px_-10px_rgba(253,184,19,0.4)]">
<div className="bg-[#FDB813] relative"> <div className="bg-[#FDB813] relative px-6 md:px-10 py-7 md:py-9">
<div className="absolute inset-0 bg-white/5 mix-blend-overlay pointer-events-none" /> <div className="absolute inset-0 bg-white/5 mix-blend-overlay pointer-events-none" />
<div className="relative grid grid-cols-1 stats-grid items-stretch px-4 md:px-6 lg:px-8 py-5 md:py-6 lg:py-7">
{/* ① Total Prestasi */} <div className="relative flex flex-col md:flex-row md:items-start gap-8 md:gap-6">
<div className="flex items-center justify-center">
<BigStat value={totalPrestasi} label={t.stats.totalPrestasi} />
</div>
<VDivider /> {/* ① Total Prestasi — 3 kolom → flex-[3] */}
<HDivider /> <StatPanel
title={lang === 'id' ? 'Total Prestasi' : 'Total Achievements'}
flexClass="flex-[4]"
>
<StatItem value={totalPrestasi} label={lang === 'id' ? 'prestasi mahasiswa' : 'student achievements'} delay={0} />
<StatItem value={internasional} label={lang === 'id' ? 'internasional' : 'international'} delay={150} />
<StatItem value={nasional} label={lang === 'id' ? 'nasional' : 'national'} delay={300} />
<StatItem value={regional} label="regional" delay={450} />
</StatPanel>
{/* ② Total Proyek */} {/* ② Total Portofolio — 1 kolom → flex-[1] */}
<div className="flex items-center justify-center"> <StatPanel
<BigStat value={totalProyek} label={t.stats.totalProyek} /> title={lang === 'id' ? 'Total Portofolio' : 'Total Portfolio'}
</div> flexClass="flex-[1.5]"
>
<VDivider /> <StatItem value={totalProyek} label={lang === 'id' ? 'proyek mahasiswa' : 'student projects'} delay={200} />
<HDivider /> </StatPanel>
{/* ③ Level breakdown */}
<div className="flex justify-center items-center px-4 md:px-0">
<div className="flex flex-col justify-center items-end gap-2 md:gap-3 lg:gap-3">
<LevelRow label="Internasional" value={internasional} delay={0} />
<LevelRow label="Nasional" value={nasional} delay={150} />
<LevelRow label="Regional" value={regional} delay={300} />
</div>
</div>
</div> </div>
</div> </div>
+1 -1
View File
@@ -5,7 +5,7 @@ const KIT_STORAGE_KEY = 'if_untan_lang';
function readKitLang(): Lang { function readKitLang(): Lang {
const saved = localStorage.getItem(KIT_STORAGE_KEY); const saved = localStorage.getItem(KIT_STORAGE_KEY);
if (saved === 'en') return 'en'; if (saved === 'id' || saved === 'en') return saved;
const browser = (navigator.language || 'id').toLowerCase(); const browser = (navigator.language || 'id').toLowerCase();
return browser.startsWith('en') ? 'en' : 'id'; return browser.startsWith('en') ? 'en' : 'id';
} }
+4 -1
View File
@@ -12,6 +12,7 @@ export interface Achievement {
generation: string; generation: string;
image: string; image: string;
avatar: string; avatar: string;
members?: Array<{nama: string; angkatan?: number}>;
} }
export interface Project { export interface Project {
@@ -20,10 +21,12 @@ export interface Project {
studentName: string; studentName: string;
nim?: string; nim?: string;
year: number; year: number;
category: string; category: string[];
description: string; description: string;
image: string; image: string;
link?: string; link?: string;
catatan?: string;
members?: Array<{nama: string; nim?: string}>;
} }
/* /*
+73 -15
View File
@@ -3,6 +3,21 @@ import { Achievement, Project } from '../data';
// Determine API Base URL dynamically from Vite env, fallback to hardcoded if not set // Determine API Base URL dynamically from Vite env, fallback to hardcoded if not set
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.ifuntanhub.dev'; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.ifuntanhub.dev';
const BIDANG_MAP: Record<number, string> = {
1: 'Kecerdasan Buatan',
2: 'Cyber Security',
3: 'Data',
4: 'Software Engineering',
5: 'Competitive Programming',
6: 'IoT',
7: 'Smart City',
8: 'Web3',
9: 'Karya Tulis Ilmiah',
10: 'Robotik',
};
/** /**
* Helper to parse Rank/Juara from Achievement title * Helper to parse Rank/Juara from Achievement title
*/ */
@@ -53,7 +68,7 @@ function extractCompetition(title: string): string {
* Fetch achievements from API * Fetch achievements from API
*/ */
export async function fetchAchievementsFromAPI(): Promise<Achievement[]> { export async function fetchAchievementsFromAPI(): Promise<Achievement[]> {
const response = await fetch(`${API_BASE_URL}/items/prestasi`); const response = await fetch(`${API_BASE_URL}/items/prestasi?fields=*.*`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch achievements: ${response.statusText}`); throw new Error(`Failed to fetch achievements: ${response.statusText}`);
} }
@@ -65,24 +80,39 @@ export async function fetchAchievementsFromAPI(): Promise<Achievement[]> {
.map((item: any) => { .map((item: any) => {
const id = String(item.id); const id = String(item.id);
const title = item.nama_prestasi || 'Untitled Achievement'; const title = item.nama_prestasi || 'Untitled Achievement';
const category = item.bidang || 'Akademik';
let category = 'Umum';
if (item.bidang_id) {
if (typeof item.bidang_id === 'object' && item.bidang_id.nama_bidang) {
category = item.bidang_id.nama_bidang;
} else {
category = BIDANG_MAP[item.bidang_id as number] || 'Umum';
}
}
const rank = extractRank(title); const rank = extractRank(title);
const level = extractLevel(title, category); const level = extractLevel(title, category);
const competition = extractCompetition(title); const competition = extractCompetition(title);
const year = parseInt(item.tahun) || new Date().getFullYear(); const year = parseInt(item.tahun) || new Date().getFullYear();
const team = item.nama_tim || 'Mahasiswa';
const anggota = Array.isArray(item.anggota) ? item.anggota : [];
const team = item.nama_tim || (anggota.length > 0 ? anggota[0].nama : 'Mahasiswa');
// Handle image url mapping for directus assets // Handle image url mapping for directus assets
const image = item.foto let image = `https://picsum.photos/seed/prestasi-${id}/800/600`;
? `${API_BASE_URL}/assets/${item.foto}` if (item.galeri && Array.isArray(item.galeri) && item.galeri.length > 0) {
: `https://picsum.photos/seed/prestasi-${id}/800/600`; const firstImg = item.galeri[0];
const imgId = typeof firstImg === 'string' ? firstImg : (firstImg.directus_files_id || firstImg.id);
if (imgId) image = `${API_BASE_URL}/assets/${imgId}`;
} else if (item.foto) {
image = `${API_BASE_URL}/assets/${item.foto}`;
}
const avatar = (item.nama_tim || item.nama_prestasi || 'IF') const avatar = (item.nama_tim || item.nama_prestasi || 'IF')
.substring(0, 2) .substring(0, 2)
.toUpperCase(); .toUpperCase();
// Create a friendly fallback description since Directus model lacks one const description = item.deskripsi || `${team} berhasil meraih prestasi luar biasa sebagai ${rank} dalam ajang ${competition} tahun ${year} pada bidang ${category.toLowerCase()}.`;
const description = `${team} berhasil meraih prestasi luar biasa sebagai ${rank} dalam ajang ${competition} tahun ${year} pada bidang ${category.toLowerCase()}.`;
return { return {
id, id,
@@ -98,6 +128,7 @@ export async function fetchAchievementsFromAPI(): Promise<Achievement[]> {
generation: item.angkatan ? `Angkatan ${item.angkatan}` : 'Informatika', generation: item.angkatan ? `Angkatan ${item.angkatan}` : 'Informatika',
image, image,
avatar, avatar,
members: anggota.map((m: any) => ({ nama: m.nama, angkatan: m.angkatan ? parseInt(m.angkatan) : undefined })),
}; };
}); });
} }
@@ -106,7 +137,7 @@ export async function fetchAchievementsFromAPI(): Promise<Achievement[]> {
* Fetch projects/portfolio from API * Fetch projects/portfolio from API
*/ */
export async function fetchProjectsFromAPI(): Promise<Project[]> { export async function fetchProjectsFromAPI(): Promise<Project[]> {
const response = await fetch(`${API_BASE_URL}/items/portfolio`); const response = await fetch(`${API_BASE_URL}/items/portfolio?fields=*,bidang.bidang_id.nama_bidang,galeri.*`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch portfolio: ${response.statusText}`); throw new Error(`Failed to fetch portfolio: ${response.statusText}`);
} }
@@ -118,18 +149,43 @@ export async function fetchProjectsFromAPI(): Promise<Project[]> {
.map((item: any) => { .map((item: any) => {
const id = String(item.id); const id = String(item.id);
const title = item.nama_proyek || 'Untitled Project'; const title = item.nama_proyek || 'Untitled Project';
const studentName = item.nama_mahasiswa || 'Mahasiswa';
const anggota = Array.isArray(item.anggota) ? item.anggota : [];
const studentName = item.nama_mahasiswa || (anggota.length > 0 ? anggota[0].nama : 'Mahasiswa');
const year = parseInt(item.tahun) || new Date().getFullYear(); const year = parseInt(item.tahun) || new Date().getFullYear();
const category = item.bidang || 'Software Development';
const description = item.deskripsi || 'Tidak ada deskripsi.'; const description = item.deskripsi || 'Tidak ada deskripsi.';
let categories: string[] = [];
if (item.bidang && Array.isArray(item.bidang)) {
categories = item.bidang.map((b: any) => {
if (b.bidang_id && typeof b.bidang_id === 'object' && b.bidang_id.nama_bidang) {
return b.bidang_id.nama_bidang;
}
if (typeof b.bidang_id === 'number') {
return BIDANG_MAP[b.bidang_id] || 'Umum';
}
return 'Umum';
}).filter(Boolean);
}
if (categories.length === 0) {
categories = ['Umum'];
}
let link = item.url || undefined; let link = item.url || undefined;
if (link && !/^https?:\/\//i.test(link)) { if (link && !/^https?:\/\//i.test(link)) {
link = `https://${link}`; link = `https://${link}`;
} }
const image = item.foto let image = `https://picsum.photos/seed/project-${id}/800/600`;
? `${API_BASE_URL}/assets/${item.foto}` if (item.galeri && Array.isArray(item.galeri) && item.galeri.length > 0) {
: `https://picsum.photos/seed/project-${id}/800/600`; const firstImg = item.galeri[0];
const imgId = typeof firstImg === 'string' ? firstImg : (firstImg.directus_files_id || firstImg.id);
if (imgId) image = `${API_BASE_URL}/assets/${imgId}`;
} else if (item.foto) {
image = `${API_BASE_URL}/assets/${item.foto}`;
}
return { return {
id, id,
@@ -137,10 +193,12 @@ export async function fetchProjectsFromAPI(): Promise<Project[]> {
studentName, studentName,
nim: item.nim || undefined, nim: item.nim || undefined,
year, year,
category, category: categories,
description, description,
image, image,
link, link,
catatan: item.catatan || undefined,
members: anggota.map((m: any) => ({ nama: m.nama, nim: m.nim || undefined })),
}; };
}); });
} }