Migrasi dari tech stack sebelumnya ke tech stack native vanilla
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 238 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,20 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ACAAB — Dev Preview</title>
|
||||
<style>
|
||||
#dev-banner {
|
||||
background: #feb401; color: #003150; font-size: 11px; font-weight: 700;
|
||||
text-align: center; padding: 6px; letter-spacing: .05em; position: sticky; top: 0; z-index: 999;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="../acaab.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="../acaab.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="dev-banner">⚡ DEV MODE — Navbar & footer tidak tampil di sini. Gunakan kit dev server (root) untuk preview lengkap.</div>
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
Generated
-2515
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "acaab-prestasi-karya",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { LanguageProvider } from './context/LanguageContext';
|
||||
import { DataProvider } from './context/DataContext';
|
||||
import Hero from './components/Hero';
|
||||
import Stats from './components/Stats';
|
||||
import AchievementGrid from './components/AchievementGrid';
|
||||
import ProjectCarousel from './components/ProjectCarousel';
|
||||
import CTA from './components/CTA';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<LanguageProvider>
|
||||
<DataProvider>
|
||||
{/* Breadcrumb */}
|
||||
<nav className="py-4" aria-label="Breadcrumb">
|
||||
<ol className="flex items-center text-[10px] md:text-xs text-slate-500 font-medium tracking-wide uppercase">
|
||||
<li>
|
||||
<a href="https://informatika.untan.ac.id" target="_blank" rel="noopener noreferrer" className="hover:text-untan-blue transition-colors">
|
||||
Beranda
|
||||
</a>
|
||||
</li>
|
||||
<li><span className="mx-2 text-slate-300" aria-hidden="true">/</span></li>
|
||||
<li>
|
||||
<span className="text-slate-800 font-bold" aria-current="page">
|
||||
Mahasiswa & Prestasi
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<Hero />
|
||||
|
||||
<section className="acaab-full-bleed pb-20">
|
||||
<div className="max-w-7xl mx-auto px-4 lg:px-8">
|
||||
<Stats />
|
||||
</div>
|
||||
<AchievementGrid />
|
||||
<ProjectCarousel />
|
||||
</section>
|
||||
|
||||
<CTA />
|
||||
</DataProvider>
|
||||
</LanguageProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
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>
|
||||
|
||||
{/* Members list */}
|
||||
{achievement.members && achievement.members.length > 0 && (
|
||||
<div className="border-t border-slate-100 pt-4 mb-4">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-2">Anggota Tim</p>
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{achievement.members.map((m, i) => (
|
||||
<li key={i} className="flex items-center gap-2 bg-slate-50 px-3 py-2 rounded-md">
|
||||
<span className="w-6 h-6 rounded-full bg-primary-gold/20 text-primary-gold flex items-center justify-center text-xs font-bold shrink-0">
|
||||
{m.nama.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-slate-700 leading-none">{m.nama}</span>
|
||||
{m.angkatan && <span className="text-[10px] text-slate-500 mt-1">Angkatan {m.angkatan}</span>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div className="border-t border-slate-100 pt-4">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-2">{t.modal.deskripsi}</p>
|
||||
<p className="text-slate-600 text-sm leading-relaxed">{achievement.description.replace(/\*\*/g, '')}</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
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.flatMap((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.join(' ').toLowerCase().includes(q) ||
|
||||
p.description.toLowerCase().includes(q);
|
||||
const matchCategory = !filterCategory || p.category.includes(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 && project.category.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{project.category.map((cat, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 text-[10px] font-bold text-primary-gold uppercase tracking-wider bg-black/30 px-2 py-1 rounded backdrop-blur-sm">
|
||||
<Tag size={10} />
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
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 && project.category.length > 0 && (
|
||||
<div className="absolute bottom-4 left-4 flex flex-wrap gap-2">
|
||||
{project.category.map((cat, i) => (
|
||||
<span key={i} className="bg-primary-gold text-slate-900 text-xs font-bold px-3 py-1.5 rounded flex items-center gap-1.5 uppercase tracking-wide">
|
||||
<Tag size={12} />
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</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 && project.category.length > 0 && (
|
||||
<InfoRow icon={<Tag size={14} />} label={t.modal.bidang} value={project.category.join(', ')} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Members list */}
|
||||
{project.members && project.members.length > 0 && (
|
||||
<div className="border-t border-slate-100 pt-4 mb-4">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-2">Anggota Tim</p>
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{project.members.map((m, i) => (
|
||||
<li key={i} className="flex items-center gap-2 bg-slate-50 px-3 py-2 rounded-md border border-slate-100">
|
||||
<span className="w-6 h-6 rounded-full bg-primary-gold/20 text-primary-gold flex items-center justify-center text-xs font-bold shrink-0">
|
||||
{m.nama.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-slate-700 leading-none">{m.nama}</span>
|
||||
{m.nim && <span className="text-[10px] text-slate-500 mt-1">{m.nim}</span>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div className="border-t border-slate-100 pt-4 mb-5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-2">{t.modal.deskripsi}</p>
|
||||
<p className="text-slate-600 text-sm leading-relaxed">{project.description}</p>
|
||||
{project.catatan && (
|
||||
<div className="mt-4 p-3 bg-blue-50/50 border border-blue-100 rounded-lg">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-blue-400 mb-1">Catatan Tambahan</p>
|
||||
<p className="text-sm text-slate-600 italic">{project.catatan}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project link */}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { Achievement, Project } from '../data';
|
||||
import { fetchAchievementsFromAPI, fetchProjectsFromAPI } from '../services/api';
|
||||
|
||||
interface DataContextType {
|
||||
achievements: Achievement[];
|
||||
projects: Project[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
const DataContext = createContext<DataContextType | undefined>(undefined);
|
||||
|
||||
export function DataProvider({ children }: { children: ReactNode }) {
|
||||
const [achievements, setAchievements] = useState<Achievement[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const results = await Promise.allSettled([
|
||||
fetchAchievementsFromAPI(),
|
||||
fetchProjectsFromAPI(),
|
||||
]);
|
||||
|
||||
const achievementsRes = results[0];
|
||||
const projectsRes = results[1];
|
||||
|
||||
let hasError = false;
|
||||
let errorMsgs: string[] = [];
|
||||
|
||||
if (achievementsRes.status === 'fulfilled') {
|
||||
setAchievements(achievementsRes.value);
|
||||
} else {
|
||||
console.error('Failed to fetch achievements:', achievementsRes.reason);
|
||||
errorMsgs.push('Gagal memuat data prestasi.');
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
if (projectsRes.status === 'fulfilled') {
|
||||
setProjects(projectsRes.value);
|
||||
} else {
|
||||
console.error('Failed to fetch projects:', projectsRes.reason);
|
||||
errorMsgs.push('Gagal memuat data portofolio.');
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
setError(errorMsgs.join(' '));
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Unexpected error loading API data:', err);
|
||||
setError('Terjadi kesalahan yang tidak terduga saat memuat data.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DataContext.Provider
|
||||
value={{
|
||||
achievements,
|
||||
projects,
|
||||
loading,
|
||||
error,
|
||||
refetch: loadData,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useData() {
|
||||
const context = useContext(DataContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useData must be used within a DataProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||
import { translations, type Lang, type Translations } from '../i18n';
|
||||
|
||||
const KIT_STORAGE_KEY = 'if_untan_lang';
|
||||
|
||||
function readKitLang(): Lang {
|
||||
const saved = localStorage.getItem(KIT_STORAGE_KEY);
|
||||
if (saved === 'id' || saved === 'en') return saved;
|
||||
const browser = (navigator.language || 'id').toLowerCase();
|
||||
return browser.startsWith('en') ? 'en' : 'id';
|
||||
}
|
||||
|
||||
interface LanguageContextType {
|
||||
lang: Lang;
|
||||
t: Translations;
|
||||
toggleLang: () => void;
|
||||
}
|
||||
|
||||
const LanguageContext = createContext<LanguageContextType | null>(null);
|
||||
|
||||
export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
const [lang, setLang] = useState<Lang>(readKitLang);
|
||||
|
||||
// Sync dengan kit's language toggle (tombol ID/EN di navbar)
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ lang: string }>).detail;
|
||||
if (detail?.lang === 'en' || detail?.lang === 'id') {
|
||||
setLang(detail.lang as Lang);
|
||||
}
|
||||
};
|
||||
document.addEventListener('languageChanged', handler);
|
||||
return () => document.removeEventListener('languageChanged', handler);
|
||||
}, []);
|
||||
|
||||
const toggleLang = () => setLang((prev) => (prev === 'id' ? 'en' : 'id'));
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={{ lang, t: translations[lang], toggleLang }}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLang() {
|
||||
const ctx = useContext(LanguageContext);
|
||||
if (!ctx) throw new Error('useLang must be used within LanguageProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
export interface Achievement {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
competition: string;
|
||||
rank: string;
|
||||
year: number;
|
||||
level: 'Nasional' | 'Internasional' | 'Regional';
|
||||
description: string;
|
||||
team: string;
|
||||
nim?: string;
|
||||
generation: string;
|
||||
image: string;
|
||||
avatar: string;
|
||||
members?: Array<{nama: string; angkatan?: number}>;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
studentName: string;
|
||||
nim?: string;
|
||||
year: number;
|
||||
category: string[];
|
||||
description: string;
|
||||
image: string;
|
||||
link?: string;
|
||||
catatan?: string;
|
||||
members?: Array<{nama: string; nim?: string}>;
|
||||
}
|
||||
|
||||
/*
|
||||
export const ACHIEVEMENTS: Achievement[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Gemastik XVI: Smart Agriculture AI System',
|
||||
category: 'Kecerdasan Buatan',
|
||||
competition: 'Gemastik XVI',
|
||||
rank: 'Juara 1',
|
||||
year: 2023,
|
||||
level: 'Nasional',
|
||||
description: 'Sistem deteksi dini penyakit tanaman menggunakan Computer Vision dan Deep Learning untuk membantu petani meningkatkan hasil panen secara berkelanjutan.',
|
||||
team: 'Tim Bima Sakti',
|
||||
nim: 'H1031201001',
|
||||
generation: 'Angkatan 2020',
|
||||
image: 'https://picsum.photos/seed/ai-agri/800/600',
|
||||
avatar: 'BS',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'ASEAN Cyber Shield Hackathon',
|
||||
category: 'Cyber Security',
|
||||
competition: 'ASEAN Cyber Shield',
|
||||
rank: '10 Besar',
|
||||
year: 2023,
|
||||
level: 'Internasional',
|
||||
description: 'Mengembangkan solusi pertahanan siber proaktif terhadap ancaman zero-day dalam kompetisi keamanan siber tingkat regional Asia Tenggara.',
|
||||
team: 'DefendUntan',
|
||||
nim: 'H1031211042',
|
||||
generation: 'Angkatan 2021',
|
||||
image: 'https://picsum.photos/seed/cyber/800/600',
|
||||
avatar: 'DU',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Innovation in Image Processing for Healthcare',
|
||||
category: 'Kecerdasan Buatan',
|
||||
competition: 'PIMNAS 36',
|
||||
rank: 'Medali Emas',
|
||||
year: 2024,
|
||||
level: 'Nasional',
|
||||
description: 'Riset mengenai otomatisasi identifikasi sel kanker melalui citra mikroskopis menggunakan arsitektur Neural Network yang dioptimalkan.',
|
||||
team: 'Alpha Medical',
|
||||
nim: 'H1031221015',
|
||||
generation: 'Angkatan 2022',
|
||||
image: 'https://picsum.photos/seed/medical/800/600',
|
||||
avatar: 'AM',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Smart Waste Management – IOT Challenge',
|
||||
category: 'IoT',
|
||||
competition: 'IOT Challenge ITS 2024',
|
||||
rank: 'Juara 2',
|
||||
year: 2024,
|
||||
level: 'Nasional',
|
||||
description: 'Sistem pengelolaan sampah cerdas berbasis sensor IoT yang mampu mendeteksi kapasitas tempat sampah dan mengoptimalkan rute pengangkutan sampah kota.',
|
||||
team: 'Tim GreenTech',
|
||||
nim: 'H1031211058',
|
||||
generation: 'Angkatan 2021',
|
||||
image: 'https://picsum.photos/seed/waste/800/600',
|
||||
avatar: 'GT',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'UXID National Design Challenge',
|
||||
category: 'Software Engineering',
|
||||
competition: 'UXID National Challenge',
|
||||
rank: 'Finalis Top 5',
|
||||
year: 2023,
|
||||
level: 'Nasional',
|
||||
description: 'Desain antarmuka aplikasi kesehatan mental yang berpusat pada pengguna remaja, mengintegrasikan prinsip aksesibilitas dan human-centered design.',
|
||||
team: 'Tim Pixel Prima',
|
||||
nim: 'H1031221033',
|
||||
generation: 'Angkatan 2022',
|
||||
image: 'https://picsum.photos/seed/ux/800/600',
|
||||
avatar: 'PP',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Pekan Ilmiah Mahasiswa – Lomba Antar Kampus Kalbar',
|
||||
category: 'Software Engineering',
|
||||
competition: 'PIM Kalbar 2024',
|
||||
rank: 'Juara 1',
|
||||
year: 2024,
|
||||
level: 'Regional',
|
||||
description: 'Aplikasi sistem informasi keuangan UMKM berbasis cloud yang memudahkan pelaku usaha kecil menengah di Kalimantan Barat dalam pengelolaan keuangan digital.',
|
||||
team: 'Tim Borneo Dev',
|
||||
nim: 'H1031231005',
|
||||
generation: 'Angkatan 2023',
|
||||
image: 'https://picsum.photos/seed/finance/800/600',
|
||||
avatar: 'BD',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
title: 'International AI for Good Hackathon – UNESCO',
|
||||
category: 'Kecerdasan Buatan',
|
||||
competition: 'AI for Good UNESCO',
|
||||
rank: 'Top 10 Global',
|
||||
year: 2024,
|
||||
level: 'Internasional',
|
||||
description: 'Solusi AI untuk deteksi deforestasi hutan tropis secara real-time menggunakan citra satelit dan machine learning, mendukung pelestarian lingkungan global.',
|
||||
team: 'Tim EarthAI',
|
||||
nim: 'H1031201022',
|
||||
generation: 'Angkatan 2020',
|
||||
image: 'https://picsum.photos/seed/deforest/800/600',
|
||||
avatar: 'EA',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
title: 'Kompetisi Antar Kampus – Data Science Cup Untan',
|
||||
category: 'Data',
|
||||
competition: 'Data Science Cup 2023',
|
||||
rank: 'Juara 2',
|
||||
year: 2023,
|
||||
level: 'Regional',
|
||||
description: 'Analisis prediktif kepadatan lalu lintas Kota Pontianak menggunakan model time-series dan visualisasi data interaktif untuk mendukung kebijakan transportasi kota.',
|
||||
team: 'Tim DataNesia',
|
||||
nim: 'H1031211077',
|
||||
generation: 'Angkatan 2021',
|
||||
image: 'https://picsum.photos/seed/datascience/800/600',
|
||||
avatar: 'DN',
|
||||
},
|
||||
];
|
||||
|
||||
export const PROJECTS: Project[] = [
|
||||
{
|
||||
id: 'p1',
|
||||
title: 'IoT Environment Monitoring System',
|
||||
studentName: 'Reza Firmansyah',
|
||||
nim: 'H1031201031',
|
||||
year: 2023,
|
||||
category: 'IoT',
|
||||
description: 'Platform pemantauan kualitas udara dan tanah secara real-time di area gambut Kalimantan Barat menggunakan sensor terdistribusi.',
|
||||
image: 'https://picsum.photos/seed/iot/800/600',
|
||||
link: 'https://github.com/informatika-untan/iot-env-monitor',
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
title: 'Smart City Data Analytics',
|
||||
studentName: 'Anisa Putri Rahmadani',
|
||||
nim: 'H1031211019',
|
||||
year: 2023,
|
||||
category: 'Data',
|
||||
description: 'Visualisasi dan analisis mobilitas kendaraan di Kota Pontianak untuk mengoptimalkan manajemen lalu lintas perkotaan.',
|
||||
image: 'https://picsum.photos/seed/analytics/800/600',
|
||||
},
|
||||
{
|
||||
id: 'p3',
|
||||
title: 'Open Education Platform',
|
||||
studentName: 'Dimas Aryo Wicaksono',
|
||||
nim: 'H1031221044',
|
||||
year: 2024,
|
||||
category: 'Software Engineering',
|
||||
description: 'Sistem manajemen pembelajaran inklusif yang dirancang khusus untuk memenuhi standar kurikulum berbasis OBE (Outcome-Based Education).',
|
||||
image: 'https://picsum.photos/seed/edu/800/600',
|
||||
link: 'https://github.com/informatika-untan/open-edu',
|
||||
},
|
||||
{
|
||||
id: 'p4',
|
||||
title: 'AI-Powered Crop Disease Detector',
|
||||
studentName: 'Siti Nur Halimah',
|
||||
nim: 'H1031201008',
|
||||
year: 2024,
|
||||
category: 'Kecerdasan Buatan',
|
||||
description: 'Aplikasi mobile berbasis deep learning untuk mendeteksi penyakit tanaman padi secara instan hanya dari foto daun menggunakan kamera smartphone.',
|
||||
image: 'https://picsum.photos/seed/cropai/800/600',
|
||||
link: 'https://github.com/informatika-untan/crop-detector',
|
||||
},
|
||||
{
|
||||
id: 'p5',
|
||||
title: 'Borneo Wildlife Tracker',
|
||||
studentName: 'Ahmad Fauzi',
|
||||
nim: 'H1031211063',
|
||||
year: 2023,
|
||||
category: 'IoT',
|
||||
description: 'Sistem pelacakan satwa liar Kalimantan menggunakan GPS collar dan dashboard web real-time untuk mendukung riset konservasi fauna.',
|
||||
image: 'https://picsum.photos/seed/wildlife/800/600',
|
||||
},
|
||||
{
|
||||
id: 'p6',
|
||||
title: 'Digital UMKM Marketplace',
|
||||
studentName: 'Lestari Dewi Kusuma',
|
||||
nim: 'H1031221028',
|
||||
year: 2024,
|
||||
category: 'Software Engineering',
|
||||
description: 'Platform e-commerce khusus UMKM Kalimantan Barat dengan fitur manajemen stok, laporan keuangan otomatis, dan integrasi pembayaran digital.',
|
||||
image: 'https://picsum.photos/seed/marketplace/800/600',
|
||||
link: 'https://github.com/informatika-untan/umkm-marketplace',
|
||||
},
|
||||
{
|
||||
id: 'p7',
|
||||
title: 'Campus Navigation AR',
|
||||
studentName: 'Bagas Prasetyo',
|
||||
nim: 'H1031231012',
|
||||
year: 2024,
|
||||
category: 'Software Engineering',
|
||||
description: 'Aplikasi augmented reality untuk navigasi kampus UNTAN yang memandu mahasiswa baru menemukan ruang kelas, lab, dan fasilitas secara interaktif.',
|
||||
image: 'https://picsum.photos/seed/ar-nav/800/600',
|
||||
},
|
||||
{
|
||||
id: 'p8',
|
||||
title: 'Flood Early Warning System',
|
||||
studentName: 'Yuni Karlina',
|
||||
nim: 'H1031211088',
|
||||
year: 2023,
|
||||
category: 'IoT',
|
||||
description: 'Sistem peringatan dini banjir berbasis jaringan sensor IoT di sepanjang Sungai Kapuas yang mengirim notifikasi real-time ke warga sekitar.',
|
||||
image: 'https://picsum.photos/seed/flood/800/600',
|
||||
link: 'https://github.com/informatika-untan/flood-warning',
|
||||
},
|
||||
{
|
||||
id: 'p9',
|
||||
title: 'Mental Health Support Chatbot',
|
||||
studentName: 'Rizky Pratama',
|
||||
nim: 'H1031221051',
|
||||
year: 2024,
|
||||
category: 'Kecerdasan Buatan',
|
||||
description: 'Chatbot AI empatik untuk dukungan kesehatan mental mahasiswa, dilengkapi deteksi sentimen dan sistem rujukan ke konselor kampus.',
|
||||
image: 'https://picsum.photos/seed/mentalbot/800/600',
|
||||
},
|
||||
];
|
||||
*/
|
||||
|
||||
export const ACHIEVEMENTS: Achievement[] = [];
|
||||
export const PROJECTS: Project[] = [];
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 238 KiB |
@@ -1,215 +0,0 @@
|
||||
export type Lang = 'id' | 'en';
|
||||
|
||||
export const translations = {
|
||||
id: {
|
||||
nav: {
|
||||
tentangKami: 'Tentang Kami',
|
||||
program: 'Program',
|
||||
sdm: 'SDM',
|
||||
fasilitas: 'Fasilitas',
|
||||
karyaAgenda: 'Karya & Agenda',
|
||||
kontak: 'Kontak',
|
||||
satuUntan: 'SATU UNTAN',
|
||||
},
|
||||
hero: {
|
||||
badge: 'MAHASISWA & PRESTASI',
|
||||
title: 'Sorotan dan Prestasi Karya Mahasiswa',
|
||||
desc: 'Galeri pencapaian, penghargaan, dan portofolio inovasi teknologi yang dikembangkan oleh mahasiswa Program Studi Informatika Universitas Tanjungpura.',
|
||||
},
|
||||
about: {
|
||||
title: 'Tentang Program Studi',
|
||||
desc: 'Program Studi Informatika Universitas Tanjungpura adalah program yang berfokus pada pengembangan ilmu komputer, rekayasa perangkat lunak, dan inovasi teknologi informasi. Mahasiswa kami aktif berprestasi di tingkat regional, nasional, maupun internasional, membawa nama baik Kalimantan Barat ke panggung dunia.',
|
||||
tag: 'Program Studi',
|
||||
},
|
||||
stats: {
|
||||
totalPrestasi: 'Total Prestasi',
|
||||
totalProyek: 'Total Proyek Mahasiswa',
|
||||
},
|
||||
achievement: {
|
||||
sectionTitle: 'Prestasi Mahasiswa',
|
||||
sectionDesc: 'Daftar pencapaian dan penghargaan yang diraih oleh mahasiswa Informatika UNTAN di berbagai kompetisi.',
|
||||
filterTitle: 'Filter Prestasi',
|
||||
searchPlaceholder: 'Cari nama, prestasi, bidang...',
|
||||
allLevel: 'Semua Tingkat',
|
||||
allField: 'Semua Bidang',
|
||||
allYear: 'Semua Tahun',
|
||||
resetFilter: 'Reset Filter',
|
||||
noData: 'Tidak ada prestasi yang sesuai filter.',
|
||||
noDataSub: 'Coba ubah pilihan filter atau kata kunci pencarian.',
|
||||
showing: 'Menampilkan',
|
||||
matching: 'prestasi yang sesuai filter',
|
||||
loadMore: 'Muat Lebih Banyak',
|
||||
showLess: 'Tampilkan Lebih Sedikit',
|
||||
allShown: 'prestasi telah ditampilkan.',
|
||||
readMore: 'Baca Selengkapnya',
|
||||
},
|
||||
project: {
|
||||
sectionTitle: 'Top Projects & Portofolio',
|
||||
sectionDesc: 'Jelajahi inovasi digital dan aplikasi nyata yang dikembangkan oleh mahasiswa Informatika UNTAN.',
|
||||
scrollHint: '← Geser untuk melihat lebih banyak →',
|
||||
explore: 'Telusuri Lanjut',
|
||||
visitLink: 'Kunjungi Proyek',
|
||||
noLink: 'Link tidak tersedia',
|
||||
by: 'oleh',
|
||||
readMore: 'Baca Selengkapnya',
|
||||
filterTitle: 'Filter Portofolio',
|
||||
searchPlaceholder: 'Cari judul, mahasiswa, bidang...',
|
||||
allField: 'Semua Bidang',
|
||||
allYear: 'Semua Tahun',
|
||||
resetFilter: 'Reset Filter',
|
||||
noData: 'Tidak ada proyek yang sesuai filter.',
|
||||
noDataSub: 'Coba ubah pilihan filter atau kata kunci pencarian.',
|
||||
showing: 'Menampilkan',
|
||||
matching: 'proyek yang sesuai filter',
|
||||
loadMore: 'Muat Lebih Banyak',
|
||||
showLess: 'Tampilkan Lebih Sedikit',
|
||||
allShown: 'proyek telah ditampilkan.',
|
||||
},
|
||||
modal: {
|
||||
close: 'Tutup',
|
||||
nim: 'NIM',
|
||||
angkatan: 'Angkatan',
|
||||
tahun: 'Tahun',
|
||||
tingkat: 'Tingkat',
|
||||
bidang: 'Bidang',
|
||||
kompetisi: 'Kompetisi',
|
||||
peringkat: 'Peringkat',
|
||||
tim: 'Tim / Nama',
|
||||
deskripsi: 'Deskripsi',
|
||||
mahasiswa: 'Mahasiswa',
|
||||
link: 'Link Proyek',
|
||||
namaProyek: 'Nama Proyek',
|
||||
},
|
||||
cta: {
|
||||
badge: 'HUBUNGI KAMI',
|
||||
title: 'Ingin Tahu Lebih Lanjut?',
|
||||
desc: 'Tertarik bergabung dengan Program Studi Informatika UNTAN, mendaftarkan prestasi mahasiswa, atau menjalin kerja sama? Hubungi kami sekarang.',
|
||||
waButton: 'Chat via WhatsApp',
|
||||
webButton: 'Kunjungi Website Kami',
|
||||
contactTitle: 'Informasi Kontak',
|
||||
emailLabel: 'Email',
|
||||
email: 'informatika@untan.ac.id',
|
||||
phone: '+62 561 577963',
|
||||
address: 'Jl. Prof. Dr. H. Hadari Nawawi, Bansir Laut, Pontianak Tenggara, 78124',
|
||||
},
|
||||
footer: {
|
||||
desc: 'Program Studi Informatika, Universitas Tanjungpura.\nJl. Prof. Dr. H. Hadari Nawawi, Bansir Laut, Kec. Pontianak Tenggara, Kota Pontianak, Kalimantan Barat 78124',
|
||||
kategori: 'Kategori',
|
||||
tautan: 'Tautan Penting',
|
||||
berita: 'Berita & Informasi',
|
||||
pengumuman: 'Pengumuman Akademik',
|
||||
prestasi: 'Prestasi Mahasiswa',
|
||||
siakad: 'SIAKAD UNTAN',
|
||||
elearning: 'E-Learning UNTAN',
|
||||
privacy: 'Kebijakan Privasi',
|
||||
terms: 'Syarat & Ketentuan',
|
||||
rights: 'All rights reserved.',
|
||||
},
|
||||
},
|
||||
|
||||
en: {
|
||||
nav: {
|
||||
tentangKami: 'About Us',
|
||||
program: 'Programs',
|
||||
sdm: 'Human Resources',
|
||||
fasilitas: 'Facilities',
|
||||
karyaAgenda: 'Works & Events',
|
||||
kontak: 'Contact',
|
||||
satuUntan: 'SATU UNTAN',
|
||||
},
|
||||
hero: {
|
||||
badge: 'STUDENTS & ACHIEVEMENTS',
|
||||
title: 'Student Spotlights & Achievements',
|
||||
desc: 'Gallery of accomplishments, awards, and technology innovation portfolios developed by Informatics students of Tanjungpura University.',
|
||||
},
|
||||
about: {
|
||||
title: 'About the Study Program',
|
||||
desc: 'The Informatics Study Program at Tanjungpura University focuses on computer science, software engineering, and information technology innovation. Our students actively excel at regional, national, and international levels, representing West Kalimantan on the world stage.',
|
||||
tag: 'Study Program',
|
||||
},
|
||||
stats: {
|
||||
totalPrestasi: 'Total Achievements',
|
||||
totalProyek: 'Total Student Projects',
|
||||
},
|
||||
achievement: {
|
||||
sectionTitle: 'Student Achievements',
|
||||
sectionDesc: 'List of awards and accomplishments by Informatics UNTAN students in various competitions.',
|
||||
filterTitle: 'Filter Achievements',
|
||||
searchPlaceholder: 'Search name, achievement, field...',
|
||||
allLevel: 'All Levels',
|
||||
allField: 'All Fields',
|
||||
allYear: 'All Years',
|
||||
resetFilter: 'Reset Filter',
|
||||
noData: 'No achievements match the current filter.',
|
||||
noDataSub: 'Try adjusting your filter or search keyword.',
|
||||
showing: 'Showing',
|
||||
matching: 'matching achievements',
|
||||
loadMore: 'Load More',
|
||||
showLess: 'Show Less',
|
||||
allShown: 'achievements displayed.',
|
||||
readMore: 'Read More',
|
||||
},
|
||||
project: {
|
||||
sectionTitle: 'Top Projects & Portfolios',
|
||||
sectionDesc: 'Explore digital innovations and real-world applications developed by Informatics UNTAN students.',
|
||||
scrollHint: '← Swipe to see more →',
|
||||
explore: 'Explore More',
|
||||
visitLink: 'Visit Project',
|
||||
noLink: 'Link unavailable',
|
||||
by: 'by',
|
||||
readMore: 'Read More',
|
||||
filterTitle: 'Filter Portfolios',
|
||||
searchPlaceholder: 'Search title, student, field...',
|
||||
allField: 'All Fields',
|
||||
allYear: 'All Years',
|
||||
resetFilter: 'Reset Filter',
|
||||
noData: 'No projects match the current filter.',
|
||||
noDataSub: 'Try adjusting your filter or search keyword.',
|
||||
showing: 'Showing',
|
||||
matching: 'matching projects',
|
||||
loadMore: 'Load More',
|
||||
showLess: 'Show Less',
|
||||
allShown: 'projects displayed.',
|
||||
},
|
||||
modal: {
|
||||
close: 'Close',
|
||||
nim: 'Student ID',
|
||||
angkatan: 'Batch',
|
||||
tahun: 'Year',
|
||||
tingkat: 'Level',
|
||||
bidang: 'Field',
|
||||
kompetisi: 'Competition',
|
||||
peringkat: 'Rank',
|
||||
tim: 'Team / Name',
|
||||
deskripsi: 'Description',
|
||||
mahasiswa: 'Student',
|
||||
link: 'Project Link',
|
||||
namaProyek: 'Project Name',
|
||||
},
|
||||
cta: {
|
||||
badge: 'CONTACT US',
|
||||
title: 'Want to Know More?',
|
||||
desc: 'Interested in joining the Informatics Study Program at UNTAN, registering a student achievement, or establishing a partnership? Contact us now.',
|
||||
waButton: 'Chat via WhatsApp',
|
||||
webButton: 'Visit Our Website',
|
||||
contactTitle: 'Contact Information',
|
||||
emailLabel: 'Email',
|
||||
address: 'Jl. Prof. Dr. H. Hadari Nawawi, Bansir Laut, Pontianak Tenggara, 78124',
|
||||
},
|
||||
footer: {
|
||||
desc: 'Informatics Study Program, Tanjungpura University.\nJl. Prof. Dr. H. Hadari Nawawi, Bansir Laut, Kec. Pontianak Tenggara, Pontianak City, West Kalimantan 78124',
|
||||
kategori: 'Categories',
|
||||
tautan: 'Important Links',
|
||||
berita: 'News & Information',
|
||||
pengumuman: 'Academic Announcements',
|
||||
prestasi: 'Student Achievements',
|
||||
siakad: 'SIAKAD UNTAN',
|
||||
elearning: 'E-Learning UNTAN',
|
||||
privacy: 'Privacy Policy',
|
||||
terms: 'Terms & Conditions',
|
||||
rights: 'All rights reserved.',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type Translations = typeof translations.id;
|
||||
@@ -1,69 +0,0 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@400;700&family=Roboto:wght@300;400;500;700&family=Merriweather:wght@700;900&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Break out of the kit's container to go full-width */
|
||||
.acaab-full-bleed {
|
||||
width: 100vw;
|
||||
position: relative;
|
||||
left: 50%;
|
||||
margin-left: -50vw;
|
||||
margin-right: -50vw;
|
||||
}
|
||||
|
||||
body { overflow-x: hidden; }
|
||||
|
||||
@theme {
|
||||
--font-sans: "Roboto", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: "Roboto Condensed", sans-serif;
|
||||
--font-serif: "Merriweather", ui-serif, Georgia, serif;
|
||||
|
||||
--color-untan-navy: #003150;
|
||||
--color-untan-yellow: #feb401;
|
||||
--color-untan-blue: #1e40af;
|
||||
--color-primary-navy: #003150;
|
||||
--color-primary-gold: #feb401;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background-color: #ffffff;
|
||||
color: #334155;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
/* Hide scrollbar for carousel while keeping scroll functionality */
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Fade-in animation for dynamically loaded cards */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.4s ease both;
|
||||
}
|
||||
|
||||
/* Explicitly scan all component files so arbitrary Tailwind classes compile */
|
||||
@source "./**/*.tsx";
|
||||
|
||||
/* Stats bar 3-column layout (replaces md:grid-cols-[1fr_auto_1fr_auto_1fr]) */
|
||||
@media (min-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr auto 1fr auto 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Project carousel card widths (replaces w-[85vw] md:w-[60vw] lg:w-[38vw]) */
|
||||
.proj-card { width: 75vw; }
|
||||
@media (min-width: 768px) { .proj-card { width: 42vw; } }
|
||||
@media (min-width: 1024px) { .proj-card { width: 28vw; } }
|
||||
@@ -1,19 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ACAAB — Dev Preview</title>
|
||||
<style>
|
||||
#dev-banner {
|
||||
background: #feb401; color: #003150; font-size: 11px; font-weight: 700;
|
||||
text-align: center; padding: 6px; letter-spacing: .05em; position: sticky; top: 0; z-index: 999;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="dev-banner">⚡ DEV MODE — Navbar & footer tidak tampil di sini. Gunakan kit dev server (root) untuk preview lengkap.</div>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,10 +0,0 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -1,204 +0,0 @@
|
||||
import { Achievement, Project } from '../data';
|
||||
|
||||
// Determine API Base URL dynamically from Vite env, fallback to hardcoded if not set
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.ifuntanhub.dev';
|
||||
|
||||
const BIDANG_MAP: Record<number, string> = {
|
||||
1: 'Kecerdasan Buatan',
|
||||
2: 'Cyber Security',
|
||||
3: 'Data',
|
||||
4: 'Software Engineering',
|
||||
5: 'Competitive Programming',
|
||||
6: 'IoT',
|
||||
7: 'Smart City',
|
||||
8: 'Web3',
|
||||
9: 'Karya Tulis Ilmiah',
|
||||
10: 'Robotik',
|
||||
};
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Helper to parse Rank/Juara from Achievement title
|
||||
*/
|
||||
function extractRank(title: string): string {
|
||||
const match = title.match(/(Juara\s+\d+|Juara\s+Harapan\s*\d*|Top\s+\d+|Finalis|Peringkat\s+\d+|Medali\s+\w+)/i);
|
||||
return match ? match[0] : 'Pemenang';
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to parse Level (Nasional, Internasional, Regional) from title/category
|
||||
*/
|
||||
function extractLevel(title: string, category: string): 'Nasional' | 'Internasional' | 'Regional' {
|
||||
const text = `${title} ${category}`.toLowerCase();
|
||||
if (
|
||||
text.includes('internasional') ||
|
||||
text.includes('international') ||
|
||||
text.includes('global') ||
|
||||
text.includes('world') ||
|
||||
text.includes('asean') ||
|
||||
text.includes('unesco')
|
||||
) {
|
||||
return 'Internasional';
|
||||
}
|
||||
if (
|
||||
text.includes('regional') ||
|
||||
text.includes('kalbar') ||
|
||||
text.includes('kalimantan') ||
|
||||
text.includes('provinsi') ||
|
||||
text.includes('kota') ||
|
||||
text.includes('kabupaten') ||
|
||||
text.includes('lokal')
|
||||
) {
|
||||
return 'Regional';
|
||||
}
|
||||
return 'Nasional'; // Default fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to extract competition name from full achievement title
|
||||
*/
|
||||
function extractCompetition(title: string): string {
|
||||
return title
|
||||
.replace(/^(Juara\s+\d+|Juara\s+Harapan\s*\d*|Top\s+\d+|Finalis|Peringkat\s+\d+|Medali\s+\w+)\s+(di\s+|pada\s+|dalam\s+)?/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch achievements from API
|
||||
*/
|
||||
export async function fetchAchievementsFromAPI(): Promise<Achievement[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/items/prestasi?fields=*.*`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch achievements: ${response.statusText}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
const data = result.data || [];
|
||||
|
||||
return data
|
||||
.filter((item: any) => item.status === 'published')
|
||||
.map((item: any) => {
|
||||
const id = String(item.id);
|
||||
const title = item.nama_prestasi || 'Untitled Achievement';
|
||||
|
||||
let category = 'Umum';
|
||||
if (item.bidang_id) {
|
||||
if (typeof item.bidang_id === 'object' && item.bidang_id.nama_bidang) {
|
||||
category = item.bidang_id.nama_bidang;
|
||||
} else {
|
||||
category = BIDANG_MAP[item.bidang_id as number] || 'Umum';
|
||||
}
|
||||
}
|
||||
|
||||
const rank = extractRank(title);
|
||||
const level = extractLevel(title, category);
|
||||
const competition = extractCompetition(title);
|
||||
const year = parseInt(item.tahun) || new Date().getFullYear();
|
||||
|
||||
const anggota = Array.isArray(item.anggota) ? item.anggota : [];
|
||||
const team = item.nama_tim || (anggota.length > 0 ? anggota[0].nama : 'Mahasiswa');
|
||||
|
||||
// Handle image url mapping for directus assets
|
||||
let image = `https://picsum.photos/seed/prestasi-${id}/800/600`;
|
||||
if (item.galeri && Array.isArray(item.galeri) && item.galeri.length > 0) {
|
||||
const firstImg = item.galeri[0];
|
||||
const imgId = typeof firstImg === 'string' ? firstImg : (firstImg.directus_files_id || firstImg.id);
|
||||
if (imgId) image = `${API_BASE_URL}/assets/${imgId}`;
|
||||
} else if (item.foto) {
|
||||
image = `${API_BASE_URL}/assets/${item.foto}`;
|
||||
}
|
||||
|
||||
const avatar = (item.nama_tim || item.nama_prestasi || 'IF')
|
||||
.substring(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
const description = item.deskripsi || `${team} berhasil meraih prestasi luar biasa sebagai ${rank} dalam ajang ${competition} tahun ${year} pada bidang ${category.toLowerCase()}.`;
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
category,
|
||||
competition,
|
||||
rank,
|
||||
year,
|
||||
level,
|
||||
description,
|
||||
team,
|
||||
nim: item.nim || undefined,
|
||||
generation: item.angkatan ? `Angkatan ${item.angkatan}` : 'Informatika',
|
||||
image,
|
||||
avatar,
|
||||
members: anggota.map((m: any) => ({ nama: m.nama, angkatan: m.angkatan ? parseInt(m.angkatan) : undefined })),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch projects/portfolio from API
|
||||
*/
|
||||
export async function fetchProjectsFromAPI(): Promise<Project[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/items/portfolio?fields=*,bidang.bidang_id.nama_bidang,galeri.*`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch portfolio: ${response.statusText}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
const data = result.data || [];
|
||||
|
||||
return data
|
||||
.filter((item: any) => item.status === 'published')
|
||||
.map((item: any) => {
|
||||
const id = String(item.id);
|
||||
const title = item.nama_proyek || 'Untitled Project';
|
||||
|
||||
const anggota = Array.isArray(item.anggota) ? item.anggota : [];
|
||||
const studentName = item.nama_mahasiswa || (anggota.length > 0 ? anggota[0].nama : 'Mahasiswa');
|
||||
|
||||
const year = parseInt(item.tahun) || new Date().getFullYear();
|
||||
const description = item.deskripsi || 'Tidak ada deskripsi.';
|
||||
|
||||
let categories: string[] = [];
|
||||
if (item.bidang && Array.isArray(item.bidang)) {
|
||||
categories = item.bidang.map((b: any) => {
|
||||
if (b.bidang_id && typeof b.bidang_id === 'object' && b.bidang_id.nama_bidang) {
|
||||
return b.bidang_id.nama_bidang;
|
||||
}
|
||||
if (typeof b.bidang_id === 'number') {
|
||||
return BIDANG_MAP[b.bidang_id] || 'Umum';
|
||||
}
|
||||
return 'Umum';
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
if (categories.length === 0) {
|
||||
categories = ['Umum'];
|
||||
}
|
||||
|
||||
let link = item.url || undefined;
|
||||
if (link && !/^https?:\/\//i.test(link)) {
|
||||
link = `https://${link}`;
|
||||
}
|
||||
|
||||
let image = `https://picsum.photos/seed/project-${id}/800/600`;
|
||||
if (item.galeri && Array.isArray(item.galeri) && item.galeri.length > 0) {
|
||||
const firstImg = item.galeri[0];
|
||||
const imgId = typeof firstImg === 'string' ? firstImg : (firstImg.directus_files_id || firstImg.id);
|
||||
if (imgId) image = `${API_BASE_URL}/assets/${imgId}`;
|
||||
} else if (item.foto) {
|
||||
image = `${API_BASE_URL}/assets/${item.foto}`;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
studentName,
|
||||
nim: item.nim || undefined,
|
||||
year,
|
||||
category: categories,
|
||||
description,
|
||||
image,
|
||||
link,
|
||||
catatan: item.catatan || undefined,
|
||||
members: anggota.map((m: any) => ({ nama: m.nama, nim: m.nim || undefined })),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { Achievement, Project } from '../data';
|
||||
|
||||
/**
|
||||
* Google Sheets Service
|
||||
*/
|
||||
|
||||
const SPREADSHEET_ID = import.meta.env.VITE_SPREADSHEET_ID;
|
||||
const API_KEY = import.meta.env.VITE_GOOGLE_API_KEY;
|
||||
|
||||
export async function fetchSheetData(range: string) {
|
||||
const url = `https://sheets.googleapis.com/v4/spreadsheets/${SPREADSHEET_ID}/values/${range}?key=${API_KEY}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.values;
|
||||
} catch (error) {
|
||||
console.error('Error fetching sheet data:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMappedData() {
|
||||
const values = await fetchSheetData('Form Responses 1!A2:J200');
|
||||
if (!values) return { achievements: [], projects: [] };
|
||||
|
||||
const achievements: Achievement[] = [];
|
||||
const projects: Project[] = [];
|
||||
|
||||
values.forEach((row: any[], index: number) => {
|
||||
// New Mapping:
|
||||
// 0: Nama Mahasiswa (A)
|
||||
// 1: NIM Mahasiswa (B)
|
||||
// 2: Angkatan (C)
|
||||
// 3: Jenis Data (D)
|
||||
// 4: Nama Prestasi (E)
|
||||
// 5: Tahun (F)
|
||||
// 6: Tingkat (G)
|
||||
// 7: Bidang (H)
|
||||
// 8: Deskripsi (I)
|
||||
// 9: Gambar (J)
|
||||
|
||||
const type = row[3]; // Jenis Data
|
||||
const id = `sheet-${index}`;
|
||||
const imageUrl = row[9] || `https://picsum.photos/seed/${id}/800/600`;
|
||||
|
||||
if (type === 'Prestasi') {
|
||||
achievements.push({
|
||||
id,
|
||||
title: row[4] || 'Untitled Achievement',
|
||||
category: row[7] || 'Umum',
|
||||
competition: row[4] || 'Kompetisi',
|
||||
rank: 'Peserta',
|
||||
year: parseInt(row[5]) || new Date().getFullYear(),
|
||||
level: (row[6] as any) || 'Nasional',
|
||||
description: row[8] || '',
|
||||
team: row[0] || 'Mahasiswa',
|
||||
nim: row[1],
|
||||
generation: `Angkatan ${row[2] || '-'}`,
|
||||
image: imageUrl,
|
||||
avatar: (row[0] || 'M').substring(0, 2).toUpperCase(),
|
||||
});
|
||||
} else if (type === 'Proyek') {
|
||||
projects.push({
|
||||
id: `p-${id}`,
|
||||
title: row[4] || 'Untitled Project',
|
||||
studentName: row[0] || 'Mahasiswa',
|
||||
nim: row[1],
|
||||
year: parseInt(row[5]) || new Date().getFullYear(),
|
||||
category: row[7] || 'Software',
|
||||
description: row[8] || '',
|
||||
image: imageUrl,
|
||||
link: undefined, // No link column mentioned in new structure
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { achievements, projects };
|
||||
}
|
||||
Vendored
-11
@@ -1,11 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_SPREADSHEET_ID: string
|
||||
readonly VITE_GOOGLE_API_KEY: string
|
||||
readonly VITE_API_BASE_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig(({ command }) => ({
|
||||
plugins: [react(), tailwindcss()],
|
||||
|
||||
// Relative base so asset URLs resolve correctly from any subdirectory
|
||||
base: './',
|
||||
|
||||
// Dev: root = src/ so HMR works against src/index.html
|
||||
// Build: root = . so rollup can resolve src/index.html as input
|
||||
root: command === 'serve' ? './src' : '.',
|
||||
|
||||
resolve: {
|
||||
alias: { '@': path.resolve(__dirname, '.') },
|
||||
},
|
||||
|
||||
server: {
|
||||
port: 3000,
|
||||
host: true,
|
||||
},
|
||||
|
||||
build: {
|
||||
outDir: path.resolve(__dirname, 'assets'),
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: path.resolve(__dirname, 'src/index.html'),
|
||||
output: {
|
||||
entryFileNames: 'acaab.js',
|
||||
chunkFileNames: 'acaab-[name].js',
|
||||
assetFileNames: (assetInfo) => {
|
||||
const name = assetInfo.names?.[0] ?? '';
|
||||
if (name.endsWith('.css')) return 'acaab.css';
|
||||
return 'acaab-[name][extname]';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user