1 Commits

9 changed files with 125 additions and 237 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -88,30 +88,10 @@ export default function AchievementModal({ achievement, onClose }: AchievementMo
<InfoRow icon={<Tag size={14} />} label={t.modal.bidang} value={achievement.category} />
</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 */}
<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-slate-600 text-sm leading-relaxed">{achievement.description.replace(/\*\*/g, '')}</p>
<p className="text-slate-600 text-sm leading-relaxed">{achievement.description}</p>
</div>
{/* Close button */}
@@ -23,7 +23,7 @@ export default function ProjectCarousel() {
const [filterYear, setFilterYear] = useState('');
const categories = useMemo(
() => [...new Set(projects.flatMap((p) => p.category))].sort(),
() => [...new Set(projects.map((p) => p.category))].sort(),
[projects]
);
@@ -40,9 +40,9 @@ export default function ProjectCarousel() {
!q ||
p.title.toLowerCase().includes(q) ||
p.studentName.toLowerCase().includes(q) ||
p.category.join(' ').toLowerCase().includes(q) ||
p.category.toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q);
const matchCategory = !filterCategory || p.category.includes(filterCategory);
const matchCategory = !filterCategory || p.category === filterCategory;
const matchYear = !filterYear || String(p.year) === filterYear;
return matchSearch && matchCategory && matchYear;
});
@@ -271,15 +271,11 @@ export default function ProjectCarousel() {
<h4 className="text-lg font-bold font-display text-white leading-snug mb-1">
{project.title}
</h4>
{project.category && project.category.length > 0 && (
<div className="flex flex-wrap gap-2 mt-1">
{project.category.map((cat, i) => (
<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">
<Tag size={10} />
{cat}
</span>
))}
</div>
{project.category && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-primary-gold uppercase tracking-wider">
<Tag size={10} />
{project.category}
</span>
)}
</div>
</div>
+8 -36
View File
@@ -56,14 +56,12 @@ export default function ProjectModal({ project, onClose }: ProjectModalProps) {
</button>
{/* Category badge */}
{project.category && project.category.length > 0 && (
<div className="absolute bottom-4 left-4 flex flex-wrap gap-2">
{project.category.map((cat, i) => (
<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">
<Tag size={12} />
{cat}
</span>
))}
{project.category && (
<div className="absolute bottom-4 left-4">
<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">
<Tag size={12} />
{project.category}
</span>
</div>
)}
</div>
@@ -83,41 +81,15 @@ export default function ProjectModal({ project, onClose }: ProjectModalProps) {
{project.year && (
<InfoRow icon={<Calendar size={14} />} label={t.modal.tahun} value={String(project.year)} />
)}
{project.category && project.category.length > 0 && (
<InfoRow icon={<Tag size={14} />} label={t.modal.bidang} value={project.category.join(', ')} />
{project.category && (
<InfoRow icon={<Tag size={14} />} label={t.modal.bidang} value={project.category} />
)}
</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 */}
<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-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>
{/* Project link */}
+55 -54
View File
@@ -55,47 +55,53 @@ function useCountUp(target: number, duration = 1600) {
return { count, elRef };
}
/* ── Big stat ──────────────────────────────────────────────────────────── */
function BigStat({ value, label }: { value: number; label: string }) {
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 }) {
/* ── Stat number with label ─────────────────────────────────────────────── */
function StatItem({ value, label, delay = 0 }: { value: number; label: string; delay?: number }) {
const { count, elRef } = useCountUp(value, 1600 + delay);
return (
<div ref={elRef} className="flex items-center gap-2 md:gap-3 lg:gap-4">
<span className="text-[10px] md:text-[11px] lg:text-[12px] leading-none font-extrabold uppercase tracking-[0.1em] text-primary-navy text-right">
{label}
</span>
<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]">
<div ref={elRef} className="flex flex-col items-center gap-1" style={{ minWidth: 'clamp(5rem, 10vw, 8rem)' }}>
<div
className="font-display font-black text-primary-navy leading-none tabular-nums"
style={{ fontSize: 'clamp(2.8rem, 6vw, 5rem)' }}
>
{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>
);
}
/* ── Dividers ───────────────────────────────────────────────────────────── */
function VDivider() {
/* ── Section panel ──────────────────────────────────────────────────────── */
function StatPanel({
title,
children,
flexClass = 'flex-1',
}: {
title: string;
children: React.ReactNode;
flexClass?: string;
}) {
return (
<div className="hidden md:flex items-center justify-center self-stretch">
<div className="w-px h-full bg-primary-navy/10 rounded-full" />
<div className={`flex flex-col gap-5 ${flexClass}`}>
{/* 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>
);
}
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 ────────────────────────────────────────────────────────── */
export default function Stats() {
@@ -119,34 +125,29 @@ export default function Stats() {
<div id="tentang">
{/* ── Stats bar ─────────────────────────────────────────────────── */}
<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="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="flex items-center justify-center">
<BigStat value={totalPrestasi} label={t.stats.totalPrestasi} />
</div>
<div className="relative flex flex-col md:flex-row md:items-start gap-8 md:gap-6">
<VDivider />
<HDivider />
{/* ① Total Prestasi — 3 kolom → flex-[3] */}
<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 */}
<div className="flex items-center justify-center">
<BigStat value={totalProyek} label={t.stats.totalProyek} />
</div>
<VDivider />
<HDivider />
{/* ③ 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>
{/* ② Total Portofolio — 1 kolom → flex-[1] */}
<StatPanel
title={lang === 'id' ? 'Total Portofolio' : 'Total Portfolio'}
flexClass="flex-[1.5]"
>
<StatItem value={totalProyek} label={lang === 'id' ? 'proyek mahasiswa' : 'student projects'} delay={200} />
</StatPanel>
</div>
</div>
+1 -1
View File
@@ -5,7 +5,7 @@ const KIT_STORAGE_KEY = 'if_untan_lang';
function readKitLang(): Lang {
const saved = localStorage.getItem(KIT_STORAGE_KEY);
if (saved === 'id' || saved === 'en') return saved;
if (saved === 'en') return 'en';
const browser = (navigator.language || 'id').toLowerCase();
return browser.startsWith('en') ? 'en' : 'id';
}
+1 -4
View File
@@ -12,7 +12,6 @@ export interface Achievement {
generation: string;
image: string;
avatar: string;
members?: Array<{nama: string; angkatan?: number}>;
}
export interface Project {
@@ -21,12 +20,10 @@ export interface Project {
studentName: string;
nim?: string;
year: number;
category: string[];
category: string;
description: string;
image: string;
link?: string;
catatan?: string;
members?: Array<{nama: string; nim?: string}>;
}
/*
+15 -73
View File
@@ -3,21 +3,6 @@ import { Achievement, Project } from '../data';
// 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 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
*/
@@ -68,7 +53,7 @@ function extractCompetition(title: string): string {
* Fetch achievements from API
*/
export async function fetchAchievementsFromAPI(): Promise<Achievement[]> {
const response = await fetch(`${API_BASE_URL}/items/prestasi?fields=*.*`);
const response = await fetch(`${API_BASE_URL}/items/prestasi`);
if (!response.ok) {
throw new Error(`Failed to fetch achievements: ${response.statusText}`);
}
@@ -80,39 +65,24 @@ export async function fetchAchievementsFromAPI(): Promise<Achievement[]> {
.map((item: any) => {
const id = String(item.id);
const title = item.nama_prestasi || 'Untitled Achievement';
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 category = item.bidang || 'Akademik';
const rank = extractRank(title);
const level = extractLevel(title, category);
const competition = extractCompetition(title);
const year = parseInt(item.tahun) || new Date().getFullYear();
const anggota = Array.isArray(item.anggota) ? item.anggota : [];
const team = item.nama_tim || (anggota.length > 0 ? anggota[0].nama : 'Mahasiswa');
const team = item.nama_tim || 'Mahasiswa';
// Handle image url mapping for directus assets
let image = `https://picsum.photos/seed/prestasi-${id}/800/600`;
if (item.galeri && Array.isArray(item.galeri) && item.galeri.length > 0) {
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 image = item.foto
? `${API_BASE_URL}/assets/${item.foto}`
: `https://picsum.photos/seed/prestasi-${id}/800/600`;
const avatar = (item.nama_tim || item.nama_prestasi || 'IF')
.substring(0, 2)
.toUpperCase();
const description = item.deskripsi || `${team} berhasil meraih prestasi luar biasa sebagai ${rank} dalam ajang ${competition} tahun ${year} pada bidang ${category.toLowerCase()}.`;
// Create a friendly fallback description since Directus model lacks one
const description = `${team} berhasil meraih prestasi luar biasa sebagai ${rank} dalam ajang ${competition} tahun ${year} pada bidang ${category.toLowerCase()}.`;
return {
id,
@@ -128,7 +98,6 @@ export async function fetchAchievementsFromAPI(): Promise<Achievement[]> {
generation: item.angkatan ? `Angkatan ${item.angkatan}` : 'Informatika',
image,
avatar,
members: anggota.map((m: any) => ({ nama: m.nama, angkatan: m.angkatan ? parseInt(m.angkatan) : undefined })),
};
});
}
@@ -137,7 +106,7 @@ export async function fetchAchievementsFromAPI(): Promise<Achievement[]> {
* Fetch projects/portfolio from API
*/
export async function fetchProjectsFromAPI(): Promise<Project[]> {
const response = await fetch(`${API_BASE_URL}/items/portfolio?fields=*,bidang.bidang_id.nama_bidang,galeri.*`);
const response = await fetch(`${API_BASE_URL}/items/portfolio`);
if (!response.ok) {
throw new Error(`Failed to fetch portfolio: ${response.statusText}`);
}
@@ -149,43 +118,18 @@ export async function fetchProjectsFromAPI(): Promise<Project[]> {
.map((item: any) => {
const id = String(item.id);
const title = item.nama_proyek || 'Untitled Project';
const anggota = Array.isArray(item.anggota) ? item.anggota : [];
const studentName = item.nama_mahasiswa || (anggota.length > 0 ? anggota[0].nama : 'Mahasiswa');
const studentName = item.nama_mahasiswa || 'Mahasiswa';
const year = parseInt(item.tahun) || new Date().getFullYear();
const category = item.bidang || 'Software Development';
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;
if (link && !/^https?:\/\//i.test(link)) {
link = `https://${link}`;
}
let image = `https://picsum.photos/seed/project-${id}/800/600`;
if (item.galeri && Array.isArray(item.galeri) && item.galeri.length > 0) {
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 image = item.foto
? `${API_BASE_URL}/assets/${item.foto}`
: `https://picsum.photos/seed/project-${id}/800/600`;
return {
id,
@@ -193,12 +137,10 @@ export async function fetchProjectsFromAPI(): Promise<Project[]> {
studentName,
nim: item.nim || undefined,
year,
category: categories,
category,
description,
image,
link,
catatan: item.catatan || undefined,
members: anggota.map((m: any) => ({ nama: m.nama, nim: m.nim || undefined })),
};
});
}