feat(acaab): implementasi halaman prestasi & karya mahasiswa

This commit is contained in:
Andrie
2026-05-29 10:30:26 +07:00
parent 0ebc25809b
commit b8099c1dca
33 changed files with 5301 additions and 51 deletions
@@ -0,0 +1,78 @@
import type { Achievement } from '../data';
import { Trophy, Calendar } from 'lucide-react';
import { useLang } from '../context/LanguageContext';
interface AchievementCardProps {
achievement: Achievement;
onReadMore: (achievement: Achievement) => void;
}
export default function AchievementCard({ achievement, onReadMore }: AchievementCardProps) {
const { t } = useLang();
return (
<article className="bg-white rounded-lg overflow-hidden group hover:-translate-y-1 transition-all duration-300 shadow-md hover:shadow-xl border border-slate-200 flex flex-col h-full relative">
<div className="absolute top-0 left-0 w-full h-1 bg-primary-navy z-10 group-hover:bg-primary-gold transition-colors"></div>
<div className="relative h-56 overflow-hidden bg-slate-100">
<img
src={achievement.image}
alt={achievement.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
referrerPolicy="no-referrer"
onError={(e) => {
(e.target as HTMLImageElement).src = 'https://placehold.co/800x600/003150/FDB813?text=Informatika+UNTAN';
}}
/>
<div className="absolute top-3 left-3 bg-white/95 backdrop-blur text-primary-navy font-bold px-3 py-1 rounded shadow-sm flex items-center gap-1.5 text-xs">
<Trophy size={14} className="text-primary-gold" />
{achievement.rank}
</div>
</div>
<div className="p-6 flex flex-col flex-grow">
<div className="flex items-center gap-3 mb-3">
<span className="text-untan-blue text-xs font-bold uppercase tracking-wider">
{achievement.category}
</span>
<span className="text-slate-400 text-xs font-medium flex items-center gap-1 border-l border-slate-300 pl-3">
<Calendar size={12} />
{achievement.year}
</span>
</div>
<h3 className="text-xl font-bold font-display text-primary-navy mb-3 leading-snug group-hover:text-untan-blue transition-colors">
{achievement.title}
</h3>
<p className="text-slate-600 text-sm leading-relaxed mb-6 line-clamp-3">
{achievement.description}
</p>
<div className="mt-auto border-t border-slate-100 pt-4">
<div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded bg-primary-navy/5 flex items-center justify-center text-primary-navy font-bold text-xs ring-1 ring-primary-navy/10">
{achievement.avatar}
</div>
<div>
<span className="font-bold text-slate-800 block text-sm">{achievement.team}</span>
<span className="text-slate-500 block text-xs">{achievement.generation}</span>
</div>
</div>
<span className="bg-slate-100 text-slate-600 text-[10px] px-2 py-1 rounded font-bold uppercase tracking-wide">
{achievement.level}
</span>
</div>
<button
onClick={() => onReadMore(achievement)}
className="w-full py-2.5 rounded bg-primary-navy/5 text-primary-navy font-bold hover:bg-primary-navy hover:text-white transition-all duration-300 text-sm min-h-[44px]"
>
{t.achievement.readMore}
</button>
</div>
</div>
</article>
);
}
@@ -0,0 +1,270 @@
import { useState, useMemo } from 'react';
import { Filter, ChevronDown, Search, X } from 'lucide-react';
import { type Achievement } from '../data';
import { useData } from '../context/DataContext';
import AchievementCard from './AchievementCard';
import AchievementModal from './AchievementModal';
import { useLang } from '../context/LanguageContext';
const INITIAL_COUNT = 3;
const LOAD_MORE_COUNT = 3;
export default function AchievementGrid() {
const { t } = useLang();
const { achievements, loading, error, refetch } = useData();
const [searchQuery, setSearchQuery] = useState('');
const [filterLevel, setFilterLevel] = useState('');
const [filterCategory, setFilterCategory] = useState('');
const [filterYear, setFilterYear] = useState('');
const [visibleCount, setVisibleCount] = useState(INITIAL_COUNT);
const [selectedAchievement, setSelectedAchievement] = useState<Achievement | null>(null);
// Derive unique categories and years from data
const categories = useMemo(
() => [...new Set(achievements.map((a) => a.category))].sort(),
[achievements]
);
const years = useMemo(
() => [...new Set(achievements.map((a) => a.year))].sort((a: number, b: number) => b - a),
[achievements]
);
// Filter + search logic
const filtered = useMemo(() => {
const q = searchQuery.toLowerCase().trim();
return achievements.filter((a) => {
const matchSearch =
!q ||
a.title.toLowerCase().includes(q) ||
a.team.toLowerCase().includes(q) ||
a.category.toLowerCase().includes(q) ||
a.competition.toLowerCase().includes(q);
const matchLevel = !filterLevel || a.level === filterLevel;
const matchCategory = !filterCategory || a.category === filterCategory;
const matchYear = !filterYear || String(a.year) === filterYear;
return matchSearch && matchLevel && matchCategory && matchYear;
});
}, [searchQuery, filterLevel, filterCategory, filterYear, achievements]);
const visible = filtered.slice(0, visibleCount);
const hasMore = visibleCount < filtered.length;
const isFiltered = searchQuery || filterLevel || filterCategory || filterYear;
const resetFilters = () => {
setSearchQuery('');
setFilterLevel('');
setFilterCategory('');
setFilterYear('');
setVisibleCount(INITIAL_COUNT);
};
const handleFilterChange = (setter: (v: string) => void) => (v: string) => {
setter(v);
setVisibleCount(INITIAL_COUNT);
};
const handleLoadMore = () => setVisibleCount((prev) => prev + LOAD_MORE_COUNT);
const handleShowLess = () => {
setVisibleCount(INITIAL_COUNT);
document.getElementById('achievement-grid-section')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
return (
<>
{/* Achievement Modal */}
<AchievementModal achievement={selectedAchievement} onClose={() => setSelectedAchievement(null)} />
<section id="achievement-grid-section" className="py-8 bg-white">
{/* Section header */}
<div className="max-w-7xl mx-auto px-4 lg:px-8 mb-8">
<h2 className="text-3xl md:text-4xl font-black font-display text-primary-navy mb-2">
{t.achievement.sectionTitle}
</h2>
<p className="text-slate-500 text-base max-w-2xl">
{t.achievement.sectionDesc}
</p>
</div>
<div className="max-w-7xl mx-auto px-4 lg:px-8">
{/* Filter + Search Section */}
<div className="bg-white/80 backdrop-blur-md p-4 rounded-lg shadow-sm border-t-4 border-primary-gold mb-8 space-y-3">
{/* Row 1: Label + Reset */}
<div className="flex items-center justify-between">
<div className="text-base font-bold font-display text-primary-navy flex items-center gap-2">
<Filter className="text-primary-navy" size={18} />
{t.achievement.filterTitle}
</div>
{isFiltered && (
<button
onClick={resetFilters}
className="flex items-center gap-1 text-xs font-bold text-slate-500 hover:text-primary-navy transition-colors"
>
<X size={13} />
{t.achievement.resetFilter}
</button>
)}
</div>
{/* Row 2: Search bar */}
<div className="relative">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
<input
id="search-achievement"
type="text"
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setVisibleCount(INITIAL_COUNT); }}
placeholder={t.achievement.searchPlaceholder}
className="w-full bg-slate-50 text-slate-700 text-sm rounded py-2 pl-9 pr-4 border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-gold"
/>
{searchQuery && (
<button
onClick={() => { setSearchQuery(''); setVisibleCount(INITIAL_COUNT); }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
aria-label="Hapus pencarian"
>
<X size={14} />
</button>
)}
</div>
{/* Row 3: Dropdowns */}
<div className="flex flex-wrap gap-3">
{/* Level Filter */}
<div className="relative flex-1 min-w-[130px]">
<select
id="filter-level"
value={filterLevel}
onChange={(e) => handleFilterChange(setFilterLevel)(e.target.value)}
className="w-full bg-slate-50 text-slate-700 font-bold text-sm rounded py-2 pl-4 pr-9 shadow-sm focus:ring-2 focus:ring-primary-gold appearance-none cursor-pointer border border-slate-200"
>
<option value="">{t.achievement.allLevel}</option>
<option value="Internasional">Internasional</option>
<option value="Nasional">Nasional</option>
<option value="Regional">Regional</option>
</select>
<ChevronDown size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
</div>
{/* Category Filter */}
<div className="relative flex-1 min-w-[130px]">
<select
id="filter-category"
value={filterCategory}
onChange={(e) => handleFilterChange(setFilterCategory)(e.target.value)}
className="w-full bg-slate-50 text-slate-700 font-bold text-sm rounded py-2 pl-4 pr-9 shadow-sm focus:ring-2 focus:ring-primary-gold appearance-none cursor-pointer border border-slate-200"
>
<option value="">{t.achievement.allField}</option>
{categories.map((cat) => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
<ChevronDown size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
</div>
{/* Year Filter */}
<div className="relative flex-1 min-w-[100px]">
<select
id="filter-year"
value={filterYear}
onChange={(e) => handleFilterChange(setFilterYear)(e.target.value)}
className="w-full bg-slate-50 text-slate-700 font-bold text-sm rounded py-2 pl-4 pr-9 shadow-sm focus:ring-2 focus:ring-primary-gold appearance-none cursor-pointer border border-slate-200"
>
<option value="">{t.achievement.allYear}</option>
{years.map((yr) => (
<option key={yr} value={String(yr)}>{yr}</option>
))}
</select>
<ChevronDown size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
</div>
</div>
</div>
{/* Results count */}
{isFiltered && (
<p className="text-sm text-slate-500 font-medium mb-6">
{t.achievement.showing}{' '}
<span className="font-bold text-primary-navy">{filtered.length}</span>{' '}
{t.achievement.matching}
</p>
)}
{/* Grid */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">
{[1, 2, 3].map((n) => (
<div key={n} className="bg-white rounded-lg border border-slate-200 overflow-hidden shadow-sm animate-pulse">
<div className="h-52 bg-slate-200 w-full" />
<div className="p-6 space-y-4">
<div className="h-4 bg-slate-200 rounded w-1/4" />
<div className="h-6 bg-slate-200 rounded w-3/4" />
<div className="h-4 bg-slate-200 rounded w-5/6" />
<div className="h-4 bg-slate-200 rounded w-2/3" />
<div className="h-10 bg-slate-200 rounded w-1/3 mt-4" />
</div>
</div>
))}
</div>
) : error ? (
<div className="text-center py-20 text-red-500">
<p className="text-lg font-semibold">Gagal memuat data</p>
<p className="text-sm mt-1">{error}</p>
<button
onClick={refetch}
className="mt-4 px-4 py-2 bg-primary-navy text-white font-bold rounded hover:bg-opacity-95 transition-all text-sm"
>
Coba Lagi
</button>
</div>
) : visible.length === 0 ? (
<div className="text-center py-20 text-slate-400">
<p className="text-lg font-semibold">{t.achievement.noData}</p>
<p className="text-sm mt-1">{t.achievement.noDataSub}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">
{visible.map((achievement) => (
<div key={achievement.id} className="animate-fade-in">
<AchievementCard
achievement={achievement}
onReadMore={(a) => setSelectedAchievement(a)}
/>
</div>
))}
</div>
)}
{/* Load More / Show Less */}
<div className="mt-20 flex flex-wrap items-center justify-center gap-6">
{hasMore && (
<button
id="load-more-achievement"
onClick={handleLoadMore}
className="text-primary-navy font-black text-lg underline underline-offset-[12px] decoration-primary-gold decoration-4 hover:text-primary-gold transition-colors flex items-center gap-2"
>
{t.achievement.loadMore}
<ChevronDown size={20} />
</button>
)}
{!hasMore && visible.length > 0 && filtered.length > INITIAL_COUNT && (
<p className="text-slate-400 text-sm font-medium">
{t.achievement.showing} {filtered.length} {t.achievement.allShown}
</p>
)}
{visibleCount > INITIAL_COUNT && visible.length > 0 && (
<button
onClick={handleShowLess}
className="text-slate-400 font-bold text-sm underline underline-offset-4 decoration-slate-300 decoration-2 hover:text-slate-600 transition-colors flex items-center gap-1.5"
>
<ChevronDown size={16} className="rotate-180" />
{t.achievement.showLess}
</button>
)}
</div>
</div>
</section>
</>
);
}
@@ -0,0 +1,108 @@
import type { ReactNode } from 'react';
import { X, Trophy, Calendar, Award, Users, BookOpen, Tag } from 'lucide-react';
import type { Achievement } from '../data';
import { useLang } from '../context/LanguageContext';
interface AchievementModalProps {
achievement: Achievement | null;
onClose: () => void;
}
function InfoRow({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
return (
<div className="flex gap-2.5 items-start">
<span className="text-primary-gold mt-0.5 shrink-0">{icon}</span>
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-400 leading-none mb-1">{label}</p>
<p className="text-sm font-semibold text-slate-800 leading-snug">{value}</p>
</div>
</div>
);
}
export default function AchievementModal({ achievement, onClose }: AchievementModalProps) {
const { t } = useLang();
if (!achievement) return null;
return (
<div
className="fixed inset-0 z-[300] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={onClose}
aria-modal="true"
role="dialog"
aria-label={achievement.title}
>
<div
className="bg-white rounded-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-2xl animate-fade-in"
onClick={(e) => e.stopPropagation()}
>
{/* Image header */}
<div className="relative h-56 overflow-hidden rounded-t-xl bg-slate-200 shrink-0">
<img
src={achievement.image}
alt={achievement.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
{/* Close button */}
<button
onClick={onClose}
aria-label={t.modal.close}
className="absolute top-3 right-3 w-9 h-9 bg-white/90 backdrop-blur rounded-full flex items-center justify-center hover:bg-white transition-colors shadow-md"
>
<X size={18} className="text-slate-700" />
</button>
{/* Rank badge */}
<div className="absolute bottom-4 left-4 flex 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">
<Trophy size={12} />
{achievement.rank}
</span>
<span className="bg-white/20 backdrop-blur text-white text-xs font-bold px-3 py-1.5 rounded uppercase tracking-wide">
{achievement.level}
</span>
</div>
</div>
{/* Content */}
<div className="p-6">
<div className="mb-1">
<span className="text-primary-untan-blue text-xs font-bold uppercase tracking-wider">
{achievement.category}
</span>
</div>
<h2 className="text-xl font-bold text-primary-navy leading-snug mb-5">{achievement.title}</h2>
{/* Info grid */}
<div className="grid grid-cols-2 gap-4 p-4 bg-slate-50 rounded-lg mb-5">
<InfoRow icon={<Users size={14} />} label={t.modal.tim} value={achievement.team} />
{achievement.nim && (
<InfoRow icon={<BookOpen size={14} />} label={t.modal.nim} value={achievement.nim} />
)}
<InfoRow icon={<Calendar size={14} />} label={t.modal.angkatan} value={achievement.generation} />
<InfoRow icon={<Calendar size={14} />} label={t.modal.tahun} value={String(achievement.year)} />
<InfoRow icon={<Award size={14} />} label={t.modal.kompetisi} value={achievement.competition} />
<InfoRow icon={<Tag size={14} />} label={t.modal.bidang} value={achievement.category} />
</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}</p>
</div>
{/* Close button */}
<button
onClick={onClose}
className="mt-6 w-full py-3 bg-primary-navy text-white font-bold rounded-lg hover:bg-untan-blue transition-colors duration-200 text-sm"
>
{t.modal.close}
</button>
</div>
</div>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { MessageCircle } from 'lucide-react';
import { useLang } from '../context/LanguageContext';
const WA_NUMBER = '6285123456789'; // Ganti dengan nomor WhatsApp resmi
export default function CTA() {
const { t, lang } = useLang();
const waLink = `https://wa.me/${WA_NUMBER}?text=${encodeURIComponent(lang === 'id' ? 'Halo, saya ingin mendaftarkan prestasi atau karya saya.' : 'Hello, I would like to register my achievement or work.')}`;
return (
<section className="acaab-full-bleed bg-untan-navy py-14 md:py-16 border-t border-white/10">
<div className="max-w-7xl mx-auto px-4 lg:px-8">
<div className="bg-untan-yellow/10 border border-untan-yellow/20 rounded-2xl px-8 py-10 md:py-12 flex flex-col md:flex-row items-center justify-between gap-8">
{/* LEFT TEXT */}
<div className="text-center md:text-left">
<span className="inline-block bg-untan-yellow text-slate-900 text-[10px] font-bold uppercase tracking-widest px-2 py-0.5 mb-4 rounded-sm">
{t.cta.badge}
</span>
<h2 className="text-white text-2xl md:text-3xl font-bold font-serif leading-snug mb-3">
{t.cta.title}
</h2>
<p className="text-slate-300 text-sm md:text-base leading-relaxed max-w-lg">
{t.cta.desc}
</p>
</div>
{/* RIGHT CTA */}
<div className="flex flex-col items-center gap-3 shrink-0">
<a
href={waLink}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2.5 bg-[#25D366] hover:bg-[#20c05a] text-white text-sm font-bold px-6 py-3 rounded-full transition-all hover:scale-105 shadow-lg shadow-[#25D366]/20 whitespace-nowrap"
>
<MessageCircle size={18} />
{t.cta.waButton}
</a>
</div>
</div>
</div>
</section>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { useLang } from '../context/LanguageContext';
export default function Footer() {
const { t } = useLang();
const currentYear = new Date().getFullYear();
return (
<footer className="bg-[#00243d] text-slate-300 py-16 border-t-[6px] border-untan-yellow">
<div className="max-w-7xl mx-auto px-4 lg:px-8">
<div className="grid grid-cols-1 md:grid-cols-4 gap-12 mb-16">
{/* Brand */}
<div className="md:col-span-2">
<div className="flex items-center gap-4 mb-6">
<img
src="https://upload.wikimedia.org/wikipedia/id/0/03/Lambang_Universitas_Tanjungpura.png"
alt="Logo UNTAN"
className="w-12 h-12 object-contain"
/>
<div className="flex flex-col">
<span className="text-xl font-bold font-display uppercase tracking-widest leading-tight text-white m-0">INFORMATIKA</span>
<span className="text-[10px] tracking-[0.15em] uppercase text-untan-yellow">UNTAN PONTIANAK</span>
</div>
</div>
<p className="text-sm font-medium leading-relaxed max-w-md text-slate-400 whitespace-pre-line">
{t.footer.desc}
</p>
</div>
{/* Kategori */}
<div>
<h4 className="text-white font-bold text-sm uppercase mb-6 tracking-wide">{t.footer.kategori}</h4>
<ul className="space-y-3 font-medium text-sm">
<li>
<a
href="https://informatika.untan.ac.id"
target="_blank"
rel="noopener noreferrer"
className="hover:text-untan-yellow transition-colors"
>
{t.footer.berita}
</a>
</li>
<li>
<a
href="https://informatika.untan.ac.id"
target="_blank"
rel="noopener noreferrer"
className="hover:text-untan-yellow transition-colors"
>
{t.footer.pengumuman}
</a>
</li>
<li>
<a
href="#achievement-grid-section"
onClick={(e) => {
e.preventDefault();
document.getElementById('achievement-grid-section')?.scrollIntoView({ behavior: 'smooth' });
}}
className="hover:text-untan-yellow transition-colors"
>
{t.footer.prestasi}
</a>
</li>
</ul>
</div>
{/* Tautan Penting */}
<div>
<h4 className="text-white font-bold text-sm uppercase mb-6 tracking-wide">{t.footer.tautan}</h4>
<ul className="space-y-3 font-medium text-sm">
<li>
<a
href="https://siakad.untan.ac.id"
target="_blank"
rel="noopener noreferrer"
className="hover:text-untan-yellow transition-colors"
>
{t.footer.siakad}
</a>
</li>
<li>
<a
href="https://elearning.untan.ac.id"
target="_blank"
rel="noopener noreferrer"
className="hover:text-untan-yellow transition-colors"
>
{t.footer.elearning}
</a>
</li>
</ul>
</div>
</div>
{/* Bottom bar */}
<div className="pt-8 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium">
<div>
© {currentYear} Program Studi Informatika Universitas Tanjungpura. {t.footer.rights}
</div>
<div className="flex gap-6">
<a href="#" className="hover:text-white transition-colors">{t.footer.privacy}</a>
<a href="#" className="hover:text-white transition-colors">{t.footer.terms}</a>
</div>
</div>
</div>
</footer>
);
}
+164
View File
@@ -0,0 +1,164 @@
import { useState, type MouseEvent } from 'react';
import { Search, Menu, X, ChevronDown, Globe } from 'lucide-react';
import { useLang } from '../context/LanguageContext';
export default function Header() {
const [isOpen, setIsOpen] = useState(false);
const { lang, t, toggleLang } = useLang();
const navLinks = [
{ label: t.nav.tentangKami, hasDropdown: true, href: '#' },
{ label: t.nav.program, hasDropdown: true, href: '#' },
{ label: t.nav.sdm, hasDropdown: false, href: '#' },
{ label: t.nav.fasilitas, hasDropdown: false, href: '#' },
{ label: t.nav.karyaAgenda, hasDropdown: false, href: '#achievement-grid-section' },
{ label: t.nav.kontak, hasDropdown: false, href: '#kontak' },
];
const handleSmoothScroll = (e: MouseEvent<HTMLAnchorElement>, href: string) => {
if (href.startsWith('#') && href.length > 1) {
e.preventDefault();
const target = document.querySelector(href);
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
setIsOpen(false);
}
}
};
return (
<>
<div id="nav-wrapper" className="fixed top-0 left-0 right-0 z-50 border-b border-slate-200 bg-white">
{/* Top Dark Bar */}
<div className="bg-untan-navy text-white py-4 lg:py-6 relative overflow-hidden hidden md:block">
<div className="absolute inset-0 w-full overflow-hidden pointer-events-none" aria-hidden="true">
<div className="absolute inset-0 opacity-[0.02] font-mono text-[10px] md:text-[12px] text-white select-none leading-snug whitespace-pre-wrap break-all text-right" style={{ overflow: 'hidden', maxHeight: '100%' }}>
01101001 01101110 01100110 01101111 01110010 01101101 01100001 01110100 01101001 01101011 01100001
</div>
<div className="absolute inset-0 bg-gradient-to-r from-untan-navy via-untan-navy/80 to-transparent"></div>
</div>
<div className="container mx-auto px-4 lg:px-6 flex justify-between items-center relative z-10">
<a href="#" className="flex items-center gap-4 group">
<div className="relative w-16 h-16 lg:w-20 lg:h-20 flex items-center justify-center shrink-0">
<img
src="https://upload.wikimedia.org/wikipedia/id/0/03/Lambang_Universitas_Tanjungpura.png"
alt="Logo UNTAN"
className="w-full h-full object-contain transition-transform duration-500 group-hover:scale-105 group-hover:rotate-3 relative z-10"
/>
</div>
<div className="border-l-2 border-white/30 pl-4 lg:pl-5 py-1 flex flex-col justify-center">
<span className="text-xl lg:text-3xl font-bold tracking-widest text-white leading-none font-sans drop-shadow-sm mb-1 uppercase">INFORMATIKA</span>
<span className="text-[10px] lg:text-sm font-normal tracking-[0.15em] lg:tracking-[0.3em] text-slate-200 mt-0.5 opacity-90 drop-shadow-sm uppercase font-sans">UNIVERSITAS TANJUNGPURA</span>
</div>
</a>
<div className="hidden lg:flex items-center gap-5">
<div className="flex items-center gap-3 text-sm">
<a
href="https://satu.untan.ac.id"
target="_blank"
rel="noopener noreferrer"
className="hover:text-untan-yellow transition-colors font-medium"
>
{t.nav.satuUntan}
</a>
</div>
<div className="pl-5 border-l border-white/20 flex items-center gap-3">
{/* Bilingual Toggle */}
<button
id="lang-toggle"
onClick={toggleLang}
aria-label={lang === 'id' ? 'Switch to English' : 'Ganti ke Bahasa Indonesia'}
className="flex items-center gap-1.5 text-xs font-bold hover:text-untan-yellow transition-colors px-2 py-1 rounded hover:bg-white/10"
>
<Globe size={14} className="opacity-80" />
<span className={lang === 'id' ? 'text-untan-yellow' : 'text-white/60'}>ID</span>
<span className="text-white/30">|</span>
<span className={lang === 'en' ? 'text-untan-yellow' : 'text-white/60'}>EN</span>
</button>
</div>
</div>
</div>
</div>
{/* Yellow Nav Bar */}
<nav className="bg-untan-yellow shadow-md border-t border-white/20 relative" id="main-nav" aria-label="Navigasi utama">
<div className="container mx-auto px-4 lg:px-6 flex items-center justify-between lg:justify-center w-full py-0 lg:py-2">
{/* Mobile: Logo */}
<div className="flex items-center gap-2 lg:hidden py-3">
<img
src="https://upload.wikimedia.org/wikipedia/id/0/03/Lambang_Universitas_Tanjungpura.png"
alt="Logo UNTAN"
className="w-10 h-10 object-contain"
/>
<div className="flex flex-col text-slate-800">
<span className="text-base font-bold tracking-widest leading-none font-sans uppercase">INFORMATIKA</span>
<span className="text-[8px] tracking-[0.15em] uppercase font-sans">UNTAN Pontianak</span>
</div>
</div>
{/* Desktop Menu */}
<div className="hidden lg:flex items-center justify-center w-full gap-1">
{navLinks.map((link) => (
<a
key={link.label}
href={link.href}
onClick={(e) => handleSmoothScroll(e, link.href)}
className="nav-menu-btn px-4 py-2.5 text-sm font-bold text-slate-800 hover:text-untan-blue rounded-sm transition-all flex items-center gap-1.5 uppercase tracking-wide"
>
<span>{link.label}</span>
{link.hasDropdown && <ChevronDown size={12} className="opacity-70" />}
</a>
))}
</div>
{/* Mobile right: lang toggle + hamburger */}
<div className="flex items-center gap-1 lg:hidden">
<button
id="lang-toggle-mobile"
onClick={toggleLang}
aria-label={lang === 'id' ? 'Switch to English' : 'Ganti ke Bahasa Indonesia'}
className="flex items-center gap-1 text-[11px] font-bold text-slate-800 px-2 py-1 rounded hover:bg-black/10 transition-colors"
>
<Globe size={13} />
<span>{lang.toUpperCase()}</span>
</button>
<button
className="w-10 h-10 flex items-center justify-center text-slate-800 rounded-sm hover:bg-black/10 transition-colors"
onClick={() => setIsOpen((prev) => !prev)}
aria-label="Toggle menu"
aria-expanded={isOpen}
>
{isOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
</div>
{/* Mobile Dropdown Menu */}
<div
className={`lg:hidden overflow-hidden transition-all duration-300 ease-in-out ${isOpen ? 'max-h-screen opacity-100' : 'max-h-0 opacity-0'}`}
>
<div className="bg-untan-yellow border-t border-black/10 flex flex-col px-4 pb-4 pt-2 gap-1">
{navLinks.map((link) => (
<a
key={link.label}
href={link.href}
onClick={(e) => handleSmoothScroll(e, link.href)}
className="w-full text-left px-4 py-3 text-sm font-bold text-slate-800 hover:bg-black/5 rounded-sm transition-colors uppercase tracking-wide flex items-center justify-between"
>
{link.label}
{link.hasDropdown && <ChevronDown size={14} className="opacity-50" />}
</a>
))}
</div>
</div>
</nav>
</div>
{/* Spacer */}
<div className="h-[72px] lg:h-[190px]"></div>
</>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useLang } from '../context/LanguageContext';
export default function Hero() {
const { t } = useLang();
return (
<div
id="hero"
className="acaab-full-bleed bg-untan-navy text-white pt-6 md:pt-8 lg:pt-10 mb-8 border-t border-white/10 relative"
>
<div className="container mx-auto px-4 lg:px-8 relative z-10">
<div className="grid lg:grid-cols-2 gap-6 lg:gap-10 items-center">
{/* LEFT CONTENT */}
<div className="max-w-3xl py-6 md:py-8 lg:py-10">
<span className="inline-block bg-untan-yellow text-slate-900 text-[10px] md:text-xs font-bold uppercase tracking-widest px-2 py-0.5 mb-3">
{t.hero.badge}
</span>
<h1 className="text-2xl md:text-3xl lg:text-[36px] font-bold font-serif leading-tight md:leading-snug tracking-tight mb-3">
{t.hero.title}
</h1>
<p className="text-slate-300 max-w-2xl text-sm md:text-base leading-relaxed">
{t.hero.desc}
</p>
</div>
{/* RIGHT IMAGE */}
<div className="relative flex justify-center lg:justify-end items-end">
{/* Glow behind image */}
<div className="absolute bottom-0 w-[300px] h-[300px] bg-cyan-300/10 rounded-full blur-3xl"></div>
<div className="relative">
<img
src={new URL('../gambar/hero.png', import.meta.url).href}
alt="Mahasiswa Informatika UNTAN"
className="
relative z-10
w-full
max-w-[300px]
md:max-w-[420px]
lg:max-w-[450px]
h-auto
object-contain
opacity-95
drop-shadow-[0_15px_35px_rgba(0,0,0,0.25)]
hover:scale-[1.02]
transition-all
duration-500
"
/>
{/* Gradient fade bawah */}
<div className="absolute bottom-0 left-0 w-full h-40 bg-gradient-to-t from-untan-navy to-transparent z-20" />
{/* Gradient fade kiri */}
<div className="absolute top-0 left-0 h-full w-20 bg-gradient-to-r from-untan-navy to-transparent z-20" />
</div>
</div>
</div>
</div>
{/* Decorative Glow */}
<div className="absolute top-0 right-0 -mr-20 -mt-20 w-80 h-80 bg-untan-yellow/10 rounded-full blur-3xl pointer-events-none"></div>
</div>
);
}
@@ -0,0 +1,374 @@
import { useData } from '../context/DataContext';
import { useRef, useState, useMemo } from 'react';
import { ArrowRight, ChevronLeft, ChevronRight, ChevronDown, User, Tag, Calendar, ExternalLink, Filter, Search, X } from 'lucide-react';
import { type Project } from '../data';
import ProjectModal from './ProjectModal';
import { useLang } from '../context/LanguageContext';
const INITIAL_COUNT = 6;
const LOAD_MORE_COUNT = 3;
export default function ProjectCarousel() {
const { t } = useLang();
const { projects, loading, error, refetch } = useData();
const scrollRef = useRef<HTMLDivElement>(null);
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [visibleCount, setVisibleCount] = useState(INITIAL_COUNT);
// Filter state
const [searchQuery, setSearchQuery] = useState('');
const [filterCategory, setFilterCategory] = useState('');
const [filterYear, setFilterYear] = useState('');
const categories = useMemo(
() => [...new Set(projects.map((p) => p.category))].sort(),
[projects]
);
const years = useMemo(
() => [...new Set(projects.map((p) => p.year))].sort((a: number, b: number) => b - a),
[projects]
);
// Filter + search logic
const filtered = useMemo(() => {
const q = searchQuery.toLowerCase().trim();
return projects.filter((p) => {
const matchSearch =
!q ||
p.title.toLowerCase().includes(q) ||
p.studentName.toLowerCase().includes(q) ||
p.category.toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q);
const matchCategory = !filterCategory || p.category === filterCategory;
const matchYear = !filterYear || String(p.year) === filterYear;
return matchSearch && matchCategory && matchYear;
});
}, [searchQuery, filterCategory, filterYear, projects]);
const visibleProjects = filtered.slice(0, visibleCount);
const hasMore = visibleCount < filtered.length;
const isFiltered = searchQuery || filterCategory || filterYear;
const resetFilters = () => {
setSearchQuery('');
setFilterCategory('');
setFilterYear('');
setVisibleCount(INITIAL_COUNT);
};
const handleFilterChange = (setter: (v: string) => void) => (v: string) => {
setter(v);
setVisibleCount(INITIAL_COUNT);
};
const handleLoadMore = () => setVisibleCount((prev) => prev + LOAD_MORE_COUNT);
const handleShowLess = () => {
setVisibleCount(INITIAL_COUNT);
document.getElementById('portofolio')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const scroll = (direction: 'left' | 'right') => {
const container = scrollRef.current;
if (!container) return;
const cardWidth = container.firstElementChild
? (container.firstElementChild as HTMLElement).offsetWidth + 24
: 400;
container.scrollBy({ left: direction === 'right' ? cardWidth : -cardWidth, behavior: 'smooth' });
};
return (
<>
{/* Project Modal */}
<ProjectModal project={selectedProject} onClose={() => setSelectedProject(null)} />
<section id="portofolio" className="py-16 bg-white relative overflow-hidden border-t border-slate-100">
<div className="max-w-7xl mx-auto px-4 lg:px-8">
{/* Header row */}
<div className="flex items-start justify-between mb-8 gap-4">
<div>
<h2 className="text-3xl md:text-4xl font-black font-display text-primary-navy mb-3">
{t.project.sectionTitle}
</h2>
<p className="text-base text-slate-500 font-medium max-w-2xl">
{t.project.sectionDesc}
</p>
</div>
{/* Scroll control buttons */}
<div className="flex items-center gap-2 shrink-0 mt-1">
<button
onClick={() => scroll('left')}
aria-label="Geser kiri"
className="w-10 h-10 rounded-full border-2 border-primary-navy/20 flex items-center justify-center text-primary-navy hover:bg-primary-navy hover:text-white hover:border-primary-navy transition-all duration-200 shadow-sm"
>
<ChevronLeft size={20} />
</button>
<button
onClick={() => scroll('right')}
aria-label="Geser kanan"
className="w-10 h-10 rounded-full border-2 border-primary-navy/20 flex items-center justify-center text-primary-navy hover:bg-primary-navy hover:text-white hover:border-primary-navy transition-all duration-200 shadow-sm"
>
<ChevronRight size={20} />
</button>
</div>
</div>
{/* Filter + Search Section */}
<div className="bg-white/80 backdrop-blur-md p-4 rounded-lg shadow-sm border-t-4 border-primary-gold mb-8 space-y-3">
{/* Row 1: Label + Reset */}
<div className="flex items-center justify-between">
<div className="text-base font-bold font-display text-primary-navy flex items-center gap-2">
<Filter className="text-primary-navy" size={18} />
{t.project.filterTitle}
</div>
{isFiltered && (
<button
onClick={resetFilters}
className="flex items-center gap-1 text-xs font-bold text-slate-500 hover:text-primary-navy transition-colors"
>
<X size={13} />
{t.project.resetFilter}
</button>
)}
</div>
{/* Row 2: Search bar */}
<div className="relative">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
<input
id="search-project"
type="text"
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setVisibleCount(INITIAL_COUNT); }}
placeholder={t.project.searchPlaceholder}
className="w-full bg-slate-50 text-slate-700 text-sm rounded py-2 pl-9 pr-4 border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-gold"
/>
{searchQuery && (
<button
onClick={() => { setSearchQuery(''); setVisibleCount(INITIAL_COUNT); }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
aria-label="Hapus pencarian"
>
<X size={14} />
</button>
)}
</div>
{/* Row 3: Dropdowns */}
<div className="flex flex-wrap gap-3">
{/* Category Filter */}
<div className="relative flex-1 min-w-[160px]">
<select
id="filter-project-category"
value={filterCategory}
onChange={(e) => handleFilterChange(setFilterCategory)(e.target.value)}
className="w-full bg-slate-50 text-slate-700 font-bold text-sm rounded py-2 pl-4 pr-9 shadow-sm focus:ring-2 focus:ring-primary-gold appearance-none cursor-pointer border border-slate-200"
>
<option value="">{t.project.allField}</option>
{categories.map((cat) => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
<ChevronDown size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
</div>
{/* Year Filter */}
<div className="relative flex-1 min-w-[120px]">
<select
id="filter-project-year"
value={filterYear}
onChange={(e) => handleFilterChange(setFilterYear)(e.target.value)}
className="w-full bg-slate-50 text-slate-700 font-bold text-sm rounded py-2 pl-4 pr-9 shadow-sm focus:ring-2 focus:ring-primary-gold appearance-none cursor-pointer border border-slate-200"
>
<option value="">{t.project.allYear}</option>
{years.map((yr) => (
<option key={yr} value={String(yr)}>{yr}</option>
))}
</select>
<ChevronDown size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
</div>
</div>
</div>
{/* Results count */}
{isFiltered && (
<p className="text-sm text-slate-500 font-medium mb-6">
{t.project.showing}{' '}
<span className="font-bold text-primary-navy">{filtered.length}</span>{' '}
{t.project.matching}
</p>
)}
{/* Cards */}
{loading ? (
<div className="flex gap-6 overflow-x-auto pb-10 hide-scrollbar">
{[1, 2, 3].map((n) => (
<div
key={n}
className="snap-start shrink-0 proj-card bg-white rounded-lg border border-slate-200 overflow-hidden shadow-md animate-pulse flex flex-col"
>
<div className="h-52 bg-slate-200 w-full" />
<div className="p-5 space-y-4 flex-grow flex flex-col justify-between">
<div className="space-y-2">
<div className="h-4 bg-slate-200 rounded w-1/4" />
<div className="h-5 bg-slate-200 rounded w-3/4" />
<div className="h-4 bg-slate-200 rounded w-5/6" />
<div className="h-4 bg-slate-200 rounded w-2/3" />
</div>
<div className="h-10 bg-slate-200 rounded w-1/3 mt-4" />
</div>
</div>
))}
</div>
) : error ? (
<div className="text-center py-10 text-red-500 w-full">
<p className="text-lg font-semibold">Gagal memuat portofolio</p>
<p className="text-sm mt-1">{error}</p>
<button
onClick={refetch}
className="mt-4 px-4 py-2 bg-primary-navy text-white font-bold rounded hover:bg-opacity-95 transition-all text-sm"
>
Coba Lagi
</button>
</div>
) : visibleProjects.length === 0 ? (
<div className="text-center py-20 text-slate-400">
<p className="text-lg font-semibold">{t.project.noData}</p>
<p className="text-sm mt-1">{t.project.noDataSub}</p>
</div>
) : (
<>
{/* Scrollable cards */}
<div
ref={scrollRef}
className="flex gap-6 overflow-x-auto pb-10 hide-scrollbar snap-x snap-mandatory"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{visibleProjects.map((project) => (
<div
key={project.id}
className="snap-start shrink-0 proj-card bg-white rounded-lg overflow-hidden shadow-md border border-slate-200 group flex flex-col relative"
>
<div className="absolute top-0 left-0 w-1 h-full bg-primary-gold z-10 transition-transform origin-bottom group-hover:scale-y-110" />
{/* Image */}
<div className="h-52 overflow-hidden relative">
<img
src={project.image}
alt={project.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
referrerPolicy="no-referrer"
onError={(e) => {
(e.target as HTMLImageElement).src = 'https://placehold.co/800x600/003150/FDB813?text=Informatika+UNTAN';
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-primary-navy/80 to-transparent p-5 flex flex-col justify-end">
<h4 className="text-lg font-bold font-display text-white leading-snug mb-1">
{project.title}
</h4>
{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>
{/* Body */}
<div className="p-5 flex flex-col flex-grow">
{/* Meta row */}
<div className="flex flex-wrap items-center gap-3 mb-3 text-xs text-slate-500 font-medium">
{project.studentName && (
<span className="flex items-center gap-1">
<User size={11} className="text-slate-400" />
{project.studentName}
</span>
)}
{project.year && (
<span className="flex items-center gap-1">
<Calendar size={11} className="text-slate-400" />
{project.year}
</span>
)}
</div>
<p className="text-slate-600 text-sm leading-relaxed mb-5 flex-grow line-clamp-3">
{project.description}
</p>
{/* Actions */}
<div className="flex items-center gap-3 mt-auto">
<button
onClick={() => setSelectedProject(project)}
className="text-primary-navy font-bold text-sm tracking-wide hover:text-untan-blue flex items-center gap-2 group/btn"
style={{ minHeight: '44px' }}
>
{t.project.readMore}
<ArrowRight size={15} className="group-hover/btn:translate-x-1 transition-transform" />
</button>
{project.link && (
<a
href={project.link}
target="_blank"
rel="noopener noreferrer"
className="ml-auto flex items-center gap-1.5 text-xs font-bold text-slate-500 hover:text-primary-navy transition-colors border border-slate-200 hover:border-primary-navy/30 rounded px-3 py-2"
style={{ minHeight: '44px' }}
>
<ExternalLink size={13} />
{t.project.visitLink}
</a>
)}
</div>
</div>
</div>
))}
</div>
{/* Scroll hint */}
<p className="text-center text-xs text-slate-400 font-medium mt-2 select-none">
{t.project.scrollHint}
</p>
{/* Load More / Show Less */}
<div className="mt-10 flex flex-wrap items-center justify-center gap-6">
{hasMore && (
<button
id="load-more-project"
onClick={handleLoadMore}
className="text-primary-navy font-black text-base underline underline-offset-[10px] decoration-primary-gold decoration-4 hover:text-primary-gold transition-colors flex items-center gap-2"
>
{t.project.loadMore}
<ChevronDown size={18} />
</button>
)}
{!hasMore && visibleProjects.length > 0 && filtered.length > INITIAL_COUNT && (
<p className="text-slate-400 text-sm font-medium">
{t.project.showing} {filtered.length} {t.project.allShown}
</p>
)}
{visibleCount > INITIAL_COUNT && visibleProjects.length > 0 && (
<button
onClick={handleShowLess}
className="text-slate-400 font-bold text-sm underline underline-offset-4 decoration-slate-300 decoration-2 hover:text-slate-600 transition-colors flex items-center gap-1.5"
>
<ChevronDown size={16} className="rotate-180" />
{t.project.showLess}
</button>
)}
</div>
</>
)}
</div>
</section>
</>
);
}
@@ -0,0 +1,123 @@
import type { ReactNode } from 'react';
import { X, ExternalLink, Users, BookOpen, Calendar, Tag, FileText } from 'lucide-react';
import type { Project } from '../data';
import { useLang } from '../context/LanguageContext';
interface ProjectModalProps {
project: Project | null;
onClose: () => void;
}
function InfoRow({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
return (
<div className="flex gap-2.5 items-start">
<span className="text-primary-gold mt-0.5 shrink-0">{icon}</span>
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-400 leading-none mb-1">{label}</p>
<p className="text-sm font-semibold text-slate-800 leading-snug">{value}</p>
</div>
</div>
);
}
export default function ProjectModal({ project, onClose }: ProjectModalProps) {
const { t } = useLang();
if (!project) return null;
return (
<div
className="fixed inset-0 z-[300] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={onClose}
aria-modal="true"
role="dialog"
aria-label={project.title}
>
<div
className="bg-white rounded-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-2xl animate-fade-in"
onClick={(e) => e.stopPropagation()}
>
{/* Image header */}
<div className="relative h-56 overflow-hidden rounded-t-xl bg-slate-200 shrink-0">
<img
src={project.image}
alt={project.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
{/* Close button */}
<button
onClick={onClose}
aria-label={t.modal.close}
className="absolute top-3 right-3 w-9 h-9 bg-white/90 backdrop-blur rounded-full flex items-center justify-center hover:bg-white transition-colors shadow-md"
>
<X size={18} className="text-slate-700" />
</button>
{/* Category badge */}
{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>
{/* Content */}
<div className="p-6">
<h2 className="text-xl font-bold text-primary-navy leading-snug mb-5">{project.title}</h2>
{/* Info grid */}
<div className="grid grid-cols-2 gap-4 p-4 bg-slate-50 rounded-lg mb-5">
{project.studentName && (
<InfoRow icon={<Users size={14} />} label={t.modal.mahasiswa} value={project.studentName} />
)}
{project.nim && (
<InfoRow icon={<BookOpen size={14} />} label={t.modal.nim} value={project.nim} />
)}
{project.year && (
<InfoRow icon={<Calendar size={14} />} label={t.modal.tahun} value={String(project.year)} />
)}
{project.category && (
<InfoRow icon={<Tag size={14} />} label={t.modal.bidang} value={project.category} />
)}
</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>
</div>
{/* Project link */}
{project.link ? (
<a
href={project.link}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full py-3 bg-primary-navy text-white font-bold rounded-lg hover:bg-untan-blue transition-colors duration-200 text-sm mb-3"
>
<ExternalLink size={16} />
{t.project.visitLink}
</a>
) : (
<div className="flex items-center justify-center gap-2 w-full py-3 bg-slate-100 text-slate-400 font-bold rounded-lg text-sm mb-3 cursor-not-allowed">
<FileText size={16} />
{t.project.noLink}
</div>
)}
<button
onClick={onClose}
className="w-full py-2.5 border-2 border-slate-200 text-slate-600 font-bold rounded-lg hover:border-slate-300 hover:bg-slate-50 transition-colors duration-200 text-sm"
>
{t.modal.close}
</button>
</div>
</div>
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
import type { ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useLang } from '../context/LanguageContext';
import { useData } from '../context/DataContext';
import { BookOpen, Award, Globe2 } from 'lucide-react';
/* ── Animated counter hook ─────────────────────────────────────────────── */
function useCountUp(target: number, duration = 1600) {
const [count, setCount] = useState(0);
const elRef = useRef<HTMLDivElement | null>(null);
const triggered = useRef(false);
useEffect(() => {
triggered.current = false;
let animationFrameId: number;
let observer: IntersectionObserver;
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !triggered.current) {
triggered.current = true;
let startTimestamp: number | null = null;
const step = (timestamp: number) => {
if (!startTimestamp) startTimestamp = timestamp;
const progress = Math.min((timestamp - startTimestamp) / duration, 1);
// easeOutQuart easing function
const easeProgress = 1 - Math.pow(1 - progress, 4);
setCount(Math.round(target * easeProgress));
if (progress < 1) {
animationFrameId = requestAnimationFrame(step);
} else {
setCount(target);
}
};
animationFrameId = requestAnimationFrame(step);
}
},
{ threshold: 0.4 },
);
if (elRef.current) observer.observe(elRef.current);
return () => {
observer.disconnect();
if (animationFrameId) cancelAnimationFrame(animationFrameId);
};
}, [target, duration]);
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 }) {
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]">
{count}
</span>
</div>
);
}
/* ── Dividers ───────────────────────────────────────────────────────────── */
function VDivider() {
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>
);
}
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() {
const { t, lang } = useLang();
const { achievements, projects } = useData();
const totalPrestasi = achievements.length;
const totalProyek = projects.length;
const internasional = achievements.filter(a => a.level === 'Internasional').length;
const nasional = achievements.filter(a => a.level === 'Nasional').length;
const regional = achievements.filter(a => a.level === 'Regional').length;
const today = new Date().toLocaleDateString(
lang === 'id' ? 'id-ID' : 'en-US',
{ day: 'numeric', month: 'long', year: 'numeric' }
);
const dataNote = lang === 'id'
? `Data Terkumpul Per ${today}`
: `Data Collected As Of ${today}`;
return (
<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="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>
<VDivider />
<HDivider />
{/* ② 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>
</div>
</div>
</section>
{/* Keterangan tanggal — di luar kotak, rata kanan */}
<p className="text-[9px] md:text-[10px] font-semibold text-slate-400 tracking-wide mb-16 text-right pr-1">
{dataNote}
</p>
</div>
);
}
/* ── Fact card helper ───────────────────────────────────────────────────── */
function FactCard({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
return (
<div className="flex items-center gap-3 bg-slate-50 rounded-lg px-4 py-3 border border-slate-100">
<span className="text-primary-gold">{icon}</span>
<div>
<p className="text-[10px] font-bold uppercase tracking-wide text-slate-400">{label}</p>
<p className="text-sm font-bold text-primary-navy">{value}</p>
</div>
</div>
);
}