forked from izu/student-web-if-development-kit
feat(acaab): implementasi halaman prestasi & karya mahasiswa
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user