5 Commits

14 changed files with 338 additions and 197 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="id" class="scroll-smooth"> <html lang="id" class="scroll-smooth">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -1148,9 +1148,9 @@
</div> </div>
<div id="assignment-body" class="prose prose-slate max-w-none"> <div id="assignment-body" class="prose prose-slate max-w-none">
<!-- ================= START: BAGIAN YANG DIEDIT KELOMPOK ================= --> <!-- ================= START: BAGIAN YANG DIEDIT KELOMPOK ================= -->
<link rel="stylesheet" href="./assets/acaab.css"> <link rel="stylesheet" href="./assets/acaab.css?v=8">
<div id="root" class="not-prose"></div> <div id="root" class="not-prose"></div>
<script type="module" src="./assets/acaab.js"></script> <script type="module" src="./assets/acaab.js?v=8"></script>
<!-- ================= END: BAGIAN YANG DIEDIT KELOMPOK ================= --> <!-- ================= END: BAGIAN YANG DIEDIT KELOMPOK ================= -->
</div> </div>
</div> </div>
+1 -1
View File
@@ -11,7 +11,7 @@ export default function App() {
<LanguageProvider> <LanguageProvider>
<DataProvider> <DataProvider>
{/* Breadcrumb */} {/* Breadcrumb */}
<nav className="py-4" aria-label="Breadcrumb"> <nav className="max-w-7xl mx-auto px-4 lg:px-8 py-4" aria-label="Breadcrumb">
<ol className="flex items-center text-[10px] md:text-xs text-slate-500 font-medium tracking-wide uppercase"> <ol className="flex items-center text-[10px] md:text-xs text-slate-500 font-medium tracking-wide uppercase">
<li> <li>
<a href="https://informatika.untan.ac.id" target="_blank" rel="noopener noreferrer" className="hover:text-untan-blue transition-colors"> <a href="https://informatika.untan.ac.id" target="_blank" rel="noopener noreferrer" className="hover:text-untan-blue transition-colors">
+18 -18
View File
@@ -14,7 +14,7 @@ export default function AchievementCard({ achievement, onReadMore }: Achievement
<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"> <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="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"> <div className="relative h-44 overflow-hidden bg-slate-100">
<img <img
src={achievement.image} src={achievement.image}
alt={achievement.title} alt={achievement.title}
@@ -24,50 +24,50 @@ export default function AchievementCard({ achievement, onReadMore }: Achievement
(e.target as HTMLImageElement).src = 'https://placehold.co/800x600/003150/FDB813?text=Informatika+UNTAN'; (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"> <div className="absolute top-2.5 left-2.5 bg-white/95 backdrop-blur text-primary-navy font-bold px-2 py-0.5 rounded shadow-sm flex items-center gap-1 text-[10px]">
<Trophy size={14} className="text-primary-gold" /> <Trophy size={12} className="text-primary-gold" />
{achievement.rank} {achievement.rank}
</div> </div>
</div> </div>
<div className="p-6 flex flex-col flex-grow"> <div className="p-4 sm:p-5 flex flex-col flex-grow">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-2 mb-2">
<span className="text-untan-blue text-xs font-bold uppercase tracking-wider"> <span className="text-untan-blue text-[10px] font-bold uppercase tracking-wider">
{achievement.category} {achievement.category}
</span> </span>
<span className="text-slate-400 text-xs font-medium flex items-center gap-1 border-l border-slate-300 pl-3"> <span className="text-slate-400 text-[10px] font-medium flex items-center gap-1 border-l border-slate-300 pl-2">
<Calendar size={12} /> <Calendar size={10} />
{achievement.year} {achievement.year}
</span> </span>
</div> </div>
<h3 className="text-xl font-bold font-display text-primary-navy mb-3 leading-snug group-hover:text-untan-blue transition-colors"> <h3 className="text-lg font-bold font-display text-primary-navy mb-2 leading-snug group-hover:text-untan-blue transition-colors">
{achievement.title} {achievement.title}
</h3> </h3>
<p className="text-slate-600 text-sm leading-relaxed mb-6 line-clamp-3"> <p className="text-slate-600 text-xs leading-relaxed mb-4 line-clamp-3">
{achievement.description} {achievement.description}
</p> </p>
<div className="mt-auto border-t border-slate-100 pt-4"> <div className="mt-auto border-t border-slate-100 pt-3">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<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"> <div className="w-7 h-7 rounded bg-primary-navy/5 flex items-center justify-center text-primary-navy font-bold text-[10px] ring-1 ring-primary-navy/10">
{achievement.avatar} {achievement.avatar}
</div> </div>
<div> <div>
<span className="font-bold text-slate-800 block text-sm">{achievement.team}</span> <span className="font-bold text-slate-800 block text-xs leading-none mb-0.5">{achievement.team}</span>
<span className="text-slate-500 block text-xs">{achievement.generation}</span> <span className="text-slate-500 block text-[10px] leading-none">{achievement.generation}</span>
</div> </div>
</div> </div>
<span className="bg-slate-100 text-slate-600 text-[10px] px-2 py-1 rounded font-bold uppercase tracking-wide"> <span className="bg-slate-100 text-slate-600 text-[9px] px-1.5 py-0.5 rounded font-bold uppercase tracking-wide">
{achievement.level} {achievement.level}
</span> </span>
</div> </div>
<button <button
onClick={() => onReadMore(achievement)} 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]" className="w-full py-2 rounded bg-primary-navy/5 text-primary-navy font-bold hover:bg-primary-navy/10 hover:text-untan-blue transition-all duration-300 text-xs min-h-[38px] flex items-center justify-center gap-1.5"
> >
{t.achievement.readMore} {t.achievement.readMore}
</button> </button>
@@ -1,4 +1,4 @@
import type { ReactNode } from 'react'; import { useEffect, type ReactNode } from 'react';
import { X, Trophy, Calendar, Award, Users, BookOpen, Tag } from 'lucide-react'; import { X, Trophy, Calendar, Award, Users, BookOpen, Tag } from 'lucide-react';
import type { Achievement } from '../data'; import type { Achievement } from '../data';
import { useLang } from '../context/LanguageContext'; import { useLang } from '../context/LanguageContext';
@@ -22,6 +22,18 @@ function InfoRow({ icon, label, value }: { icon: ReactNode; label: string; value
export default function AchievementModal({ achievement, onClose }: AchievementModalProps) { export default function AchievementModal({ achievement, onClose }: AchievementModalProps) {
const { t } = useLang(); const { t } = useLang();
useEffect(() => {
if (achievement) {
document.documentElement.style.overflow = 'hidden';
document.body.style.overflow = 'hidden';
}
return () => {
document.documentElement.style.overflow = '';
document.body.style.overflow = '';
};
}, [achievement]);
if (!achievement) return null; if (!achievement) return null;
return ( return (
@@ -33,11 +45,11 @@ export default function AchievementModal({ achievement, onClose }: AchievementMo
aria-label={achievement.title} aria-label={achievement.title}
> >
<div <div
className="bg-white rounded-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-2xl animate-fade-in" className="bg-white rounded-xl max-w-lg w-full max-h-[90vh] overflow-y-auto shadow-2xl animate-fade-in"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Image header */} {/* Image header */}
<div className="relative h-56 overflow-hidden rounded-t-xl bg-slate-200 shrink-0"> <div className="relative h-56 md:h-64 overflow-hidden rounded-t-xl bg-slate-200 shrink-0">
<img <img
src={achievement.image} src={achievement.image}
alt={achievement.title} alt={achievement.title}
@@ -46,61 +58,60 @@ export default function AchievementModal({ achievement, onClose }: AchievementMo
/> />
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
{/* Close button */} {/* Close button — dark background so icon always visible on any image */}
<button <button
onClick={onClose} onClick={onClose}
aria-label={t.modal.close} 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" className="absolute top-3 right-3 z-50 w-8 h-8 bg-slate-900/50 hover:bg-slate-900/80 backdrop-blur rounded-full flex items-center justify-center transition-colors shadow-md"
> >
<X size={18} className="text-slate-700" /> <X size={16} className="text-white" />
</button> </button>
{/* Rank badge */} {/* Rank Badge only */}
<div className="absolute bottom-4 left-4 flex gap-2"> <div className="absolute bottom-3 left-4">
<span className="bg-primary-gold text-slate-900 text-xs font-bold px-3 py-1.5 rounded flex items-center gap-1.5"> <span className="bg-primary-gold text-black text-[10px] font-bold px-2.5 py-1 rounded shadow-sm flex items-center gap-1">
<Trophy size={12} />
{achievement.rank} {achievement.rank}
</span> </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>
</div> </div>
{/* Content */} {/* Content */}
<div className="p-6"> <div className="p-6">
<div className="mb-1"> <h2 className="text-lg font-bold text-slate-900 leading-snug mb-1">{achievement.title}</h2>
<span className="text-primary-untan-blue text-xs font-bold uppercase tracking-wider">
{achievement.category} <div className="text-xs text-slate-400 mb-4 font-medium">
</span> {achievement.year} &bull; {achievement.category} &bull; {achievement.level}
</div> </div>
<h2 className="text-xl font-bold text-primary-navy leading-snug mb-5">{achievement.title}</h2>
<p className="text-slate-600 text-xs leading-relaxed mb-5">
{achievement.description.replace(/\*\*/g, '')}
</p>
{/* Info grid */} {/* Info grid */}
<div className="grid grid-cols-2 gap-4 p-4 bg-slate-50 rounded-lg mb-5"> <div className="grid grid-cols-2 gap-4 mb-4 text-xs">
<InfoRow icon={<Users size={14} />} label={t.modal.tim} value={achievement.team} /> <div>
{achievement.nim && ( <p className="text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-1">Tim / Peserta</p>
<InfoRow icon={<BookOpen size={14} />} label={t.modal.nim} value={achievement.nim} /> <p className="font-bold text-slate-900">{achievement.team}</p>
</div>
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-1">Angkatan</p>
<p className="font-bold text-slate-900">{achievement.generation}</p>
</div>
</div>
{/* Members list */}
{achievement.members && achievement.members.length > 0 && (
<div className="border-t border-slate-100 pt-3">
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-2">Anggota Tim</p>
<div className="flex flex-wrap gap-1.5">
{achievement.members.map((m, i) => (
<span key={i} className="bg-slate-100 text-slate-600 text-[10px] px-2.5 py-1 rounded">
{m.nama}{m.angkatan ? ` (${m.angkatan})` : ''}
</span>
))}
</div>
</div>
)} )}
<InfoRow icon={<Calendar size={14} />} label={t.modal.angkatan} value={achievement.generation} />
<InfoRow icon={<Calendar size={14} />} label={t.modal.tahun} value={String(achievement.year)} />
<InfoRow icon={<Award size={14} />} label={t.modal.kompetisi} value={achievement.competition} />
<InfoRow icon={<Tag size={14} />} label={t.modal.bidang} value={achievement.category} />
</div>
{/* Description */}
<div className="border-t border-slate-100 pt-4">
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-2">{t.modal.deskripsi}</p>
<p className="text-slate-600 text-sm leading-relaxed">{achievement.description}</p>
</div>
{/* Close button */}
<button
onClick={onClose}
className="mt-6 w-full py-3 bg-primary-navy text-white font-bold rounded-lg hover:bg-untan-blue transition-colors duration-200 text-sm"
>
{t.modal.close}
</button>
</div> </div>
</div> </div>
</div> </div>
+1 -1
View File
@@ -6,7 +6,7 @@ export default function Hero() {
return ( return (
<div <div
id="hero" 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" 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 overflow-hidden"
> >
<div className="container mx-auto px-4 lg:px-8 relative z-10"> <div className="container mx-auto px-4 lg:px-8 relative z-10">
+40 -11
View File
@@ -23,7 +23,7 @@ export default function ProjectCarousel() {
const [filterYear, setFilterYear] = useState(''); const [filterYear, setFilterYear] = useState('');
const categories = useMemo( const categories = useMemo(
() => [...new Set(projects.map((p) => p.category))].sort(), () => [...new Set(projects.flatMap((p) => p.category))].sort(),
[projects] [projects]
); );
@@ -40,9 +40,9 @@ export default function ProjectCarousel() {
!q || !q ||
p.title.toLowerCase().includes(q) || p.title.toLowerCase().includes(q) ||
p.studentName.toLowerCase().includes(q) || p.studentName.toLowerCase().includes(q) ||
p.category.toLowerCase().includes(q) || p.category.join(' ').toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q); p.description.toLowerCase().includes(q);
const matchCategory = !filterCategory || p.category === filterCategory; const matchCategory = !filterCategory || p.category.includes(filterCategory);
const matchYear = !filterYear || String(p.year) === filterYear; const matchYear = !filterYear || String(p.year) === filterYear;
return matchSearch && matchCategory && matchYear; return matchSearch && matchCategory && matchYear;
}); });
@@ -267,17 +267,46 @@ export default function ProjectCarousel() {
(e.target as HTMLImageElement).src = 'https://placehold.co/800x600/003150/FDB813?text=Informatika+UNTAN'; (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"> {/* Tag at top-left, raised like achievements, max 2 tags then (..) */}
{project.title} {project.category && project.category.length > 0 && (
</h4> <div className="absolute top-3 left-3 flex flex-wrap gap-1.5 z-20">
{project.category && ( {project.category.slice(0, 2).map((cat, i) => (
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-primary-gold uppercase tracking-wider"> <span
<Tag size={10} /> key={i}
{project.category} className="bg-white/95 backdrop-blur text-primary-navy font-bold px-2.5 py-1 rounded shadow-sm flex items-center gap-1 text-[10px] uppercase tracking-wider"
>
<Tag size={10} className="text-primary-gold" />
{cat}
</span>
))}
{project.category.length > 2 && (
<span className="bg-white/95 backdrop-blur text-primary-navy font-bold px-2 py-1 rounded shadow-sm flex items-center gap-1 text-[10px]">
(..)
</span> </span>
)} )}
</div> </div>
)}
{/* Title block — inline background follows text width */}
<div className="absolute bottom-3 left-3 z-20 max-w-[85%]">
<h4
className="text-sm font-bold font-display text-slate-900 leading-snug"
style={{ display: 'inline' }}
>
<span
className="bg-white/55 backdrop-blur-sm px-2 py-0.5 rounded"
style={{
boxDecorationBreak: 'clone',
WebkitBoxDecorationBreak: 'clone',
lineHeight: '1.9',
textShadow: '0 0 8px rgba(255,255,255,0.8)',
}}
>
{project.title}
</span>
</h4>
</div>
</div> </div>
{/* Body */} {/* Body */}
+66 -42
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from 'react'; import { useEffect, type ReactNode } from 'react';
import { X, ExternalLink, Users, BookOpen, Calendar, Tag, FileText } from 'lucide-react'; import { X, ExternalLink, Users, BookOpen, Calendar, Tag, FileText } from 'lucide-react';
import type { Project } from '../data'; import type { Project } from '../data';
import { useLang } from '../context/LanguageContext'; import { useLang } from '../context/LanguageContext';
@@ -22,6 +22,18 @@ function InfoRow({ icon, label, value }: { icon: ReactNode; label: string; value
export default function ProjectModal({ project, onClose }: ProjectModalProps) { export default function ProjectModal({ project, onClose }: ProjectModalProps) {
const { t } = useLang(); const { t } = useLang();
useEffect(() => {
if (project) {
document.documentElement.style.overflow = 'hidden';
document.body.style.overflow = 'hidden';
}
return () => {
document.documentElement.style.overflow = '';
document.body.style.overflow = '';
};
}, [project]);
if (!project) return null; if (!project) return null;
return ( return (
@@ -33,11 +45,11 @@ export default function ProjectModal({ project, onClose }: ProjectModalProps) {
aria-label={project.title} aria-label={project.title}
> >
<div <div
className="bg-white rounded-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-2xl animate-fade-in" className="bg-white rounded-xl max-w-lg w-full max-h-[90vh] overflow-y-auto shadow-2xl animate-fade-in"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Image header */} {/* Image header */}
<div className="relative h-56 overflow-hidden rounded-t-xl bg-slate-200 shrink-0"> <div className="relative h-56 md:h-64 overflow-hidden rounded-t-xl bg-slate-200 shrink-0">
<img <img
src={project.image} src={project.image}
alt={project.title} alt={project.title}
@@ -46,76 +58,88 @@ export default function ProjectModal({ project, onClose }: ProjectModalProps) {
/> />
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
{/* Close button */} {/* Close button — dark bg so icon always visible on any image */}
<button <button
onClick={onClose} onClick={onClose}
aria-label={t.modal.close} 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" className="absolute top-3 right-3 z-50 w-8 h-8 bg-slate-900/50 hover:bg-slate-900/80 backdrop-blur rounded-full flex items-center justify-center transition-colors shadow-md"
> >
<X size={18} className="text-slate-700" /> <X size={16} className="text-white" />
</button> </button>
{/* Category badge */} {/* Category badge */}
{project.category && ( {project.category && project.category.length > 0 && (
<div className="absolute bottom-4 left-4"> <div className="absolute bottom-3 left-4 flex flex-wrap gap-1.5">
<span className="bg-primary-gold text-slate-900 text-xs font-bold px-3 py-1.5 rounded flex items-center gap-1.5 uppercase tracking-wide"> {project.category.map((cat, i) => (
<Tag size={12} /> <span key={i} className="bg-primary-gold text-slate-900 text-[10px] font-bold px-2.5 py-1 rounded shadow-sm flex items-center gap-1 uppercase tracking-wide">
{project.category} {cat}
</span> </span>
))}
</div> </div>
)} )}
</div> </div>
{/* Content */} {/* Content */}
<div className="p-6"> <div className="p-6">
<h2 className="text-xl font-bold text-primary-navy leading-snug mb-5">{project.title}</h2> <h2 className="text-lg font-bold text-slate-900 leading-snug mb-1">{project.title}</h2>
{project.category && project.category.length > 0 && (
<div className="text-xs text-slate-400 mb-4 font-medium">
{project.year} &bull; {project.category.join(', ')}
</div>
)}
<p className="text-slate-600 text-xs leading-relaxed mb-5">
{project.description}
</p>
{/* Info grid */} {/* Info grid */}
<div className="grid grid-cols-2 gap-4 p-4 bg-slate-50 rounded-lg mb-5"> <div className="grid grid-cols-2 gap-4 mb-4 text-xs">
{project.studentName && ( <div>
<InfoRow icon={<Users size={14} />} label={t.modal.mahasiswa} value={project.studentName} /> <p className="text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-1">Mahasiswa</p>
)} <p className="font-bold text-slate-900">{project.studentName}</p>
</div>
{project.nim && ( {project.nim && (
<InfoRow icon={<BookOpen size={14} />} label={t.modal.nim} value={project.nim} /> <div>
)} <p className="text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-1">NIM</p>
{project.year && ( <p className="font-bold text-slate-900">{project.nim}</p>
<InfoRow icon={<Calendar size={14} />} label={t.modal.tahun} value={String(project.year)} /> </div>
)}
{project.category && (
<InfoRow icon={<Tag size={14} />} label={t.modal.bidang} value={project.category} />
)} )}
</div> </div>
{/* Description */} {/* Members list */}
<div className="border-t border-slate-100 pt-4 mb-5"> {project.members && project.members.length > 0 && (
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-2">{t.modal.deskripsi}</p> <div className="border-t border-slate-100 pt-3 mb-4">
<p className="text-slate-600 text-sm leading-relaxed">{project.description}</p> <p className="text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-2">Anggota Tim</p>
<div className="flex flex-wrap gap-1.5">
{project.members.map((m, i) => (
<span key={i} className="bg-slate-100 text-slate-600 text-[10px] px-2.5 py-1 rounded">
{m.nama}{m.nim ? ` (${m.nim})` : ''}
</span>
))}
</div> </div>
</div>
)}
{project.catatan && (
<div className="mb-4 p-2.5 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-xs text-slate-600 italic">{project.catatan}</p>
</div>
)}
{/* Project link */} {/* Project link */}
{project.link ? ( {project.link && (
<a <a
href={project.link} href={project.link}
target="_blank" target="_blank"
rel="noopener noreferrer" 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" className="flex items-center justify-center gap-2 w-full py-2.5 bg-[#003150] text-white font-bold rounded-lg hover:bg-[#1e40af] transition-colors duration-200 text-sm"
> >
<ExternalLink size={16} /> <ExternalLink size={14} />
{t.project.visitLink} {t.project.visitLink}
</a> </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> </div>
</div> </div>
+5 -2
View File
@@ -81,10 +81,12 @@ function StatPanel({
title, title,
children, children,
flexClass = 'flex-1', flexClass = 'flex-1',
cols = 'grid-cols-2 sm:grid-cols-4',
}: { }: {
title: string; title: string;
children: React.ReactNode; children: React.ReactNode;
flexClass?: string; flexClass?: string;
cols?: string;
}) { }) {
return ( return (
<div className={`flex flex-col gap-5 ${flexClass}`}> <div className={`flex flex-col gap-5 ${flexClass}`}>
@@ -95,8 +97,8 @@ function StatPanel({
> >
{title} {title}
</div> </div>
{/* Numbers row — equal spacing */} {/* Numbers row — responsive grid */}
<div className="flex items-end justify-evenly pb-2"> <div className={`grid ${cols} items-end justify-items-center gap-y-6 gap-x-2 pb-2`}>
{children} {children}
</div> </div>
</div> </div>
@@ -145,6 +147,7 @@ export default function Stats() {
<StatPanel <StatPanel
title={lang === 'id' ? 'Total Portofolio' : 'Total Portfolio'} title={lang === 'id' ? 'Total Portofolio' : 'Total Portfolio'}
flexClass="flex-[1.5]" flexClass="flex-[1.5]"
cols="grid-cols-1"
> >
<StatItem value={totalProyek} label={lang === 'id' ? 'proyek mahasiswa' : 'student projects'} delay={200} /> <StatItem value={totalProyek} label={lang === 'id' ? 'proyek mahasiswa' : 'student projects'} delay={200} />
</StatPanel> </StatPanel>
+1 -1
View File
@@ -5,7 +5,7 @@ const KIT_STORAGE_KEY = 'if_untan_lang';
function readKitLang(): Lang { function readKitLang(): Lang {
const saved = localStorage.getItem(KIT_STORAGE_KEY); const saved = localStorage.getItem(KIT_STORAGE_KEY);
if (saved === 'en') return 'en'; if (saved === 'id' || saved === 'en') return saved;
const browser = (navigator.language || 'id').toLowerCase(); const browser = (navigator.language || 'id').toLowerCase();
return browser.startsWith('en') ? 'en' : 'id'; return browser.startsWith('en') ? 'en' : 'id';
} }
+4 -1
View File
@@ -12,6 +12,7 @@ export interface Achievement {
generation: string; generation: string;
image: string; image: string;
avatar: string; avatar: string;
members?: Array<{nama: string; angkatan?: number}>;
} }
export interface Project { export interface Project {
@@ -20,10 +21,12 @@ export interface Project {
studentName: string; studentName: string;
nim?: string; nim?: string;
year: number; year: number;
category: string; category: string[];
description: string; description: string;
image: string; image: string;
link?: string; link?: string;
catatan?: string;
members?: Array<{nama: string; nim?: string}>;
} }
/* /*
+41 -8
View File
@@ -1,16 +1,49 @@
@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 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"; @import "tailwindcss";
/* Break out of the kit's container to go full-width */ /* Override parent index.html container to allow full-width React content */
.acaab-full-bleed { #main-content > .container {
width: 100vw; max-width: 100% !important;
position: relative; width: 100% !important;
left: 50%; padding-left: 0 !important;
margin-left: -50vw; padding-right: 0 !important;
margin-right: -50vw; padding-top: 0 !important;
padding-bottom: 0 !important;
margin-left: 0 !important;
margin-right: 0 !important;
} }
body { overflow-x: hidden; } /* Re-apply standard container constraints to the static title block in index.html */
#main-content > .container > div.border-b {
max-width: 80rem; /* max-w-7xl */
margin-left: auto;
margin-right: auto;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 3rem; /* py-12 = 3rem */
padding-bottom: 2rem;
}
@media (min-width: 1024px) {
#main-content > .container > div.border-b {
padding-left: 2rem;
padding-right: 2rem;
}
}
/* Clean, modern full-bleed styling since parent is now full-width */
.acaab-full-bleed {
width: 100%;
}
/* Prevent horizontal overflow on html, body, and the assignment wrapper */
html, body {
overflow-x: hidden !important;
width: 100% !important;
}
#assignment-body {
overflow-x: hidden !important;
width: 100% !important;
}
@theme { @theme {
--font-sans: "Roboto", ui-sans-serif, system-ui, sans-serif; --font-sans: "Roboto", ui-sans-serif, system-ui, sans-serif;
+73 -15
View File
@@ -3,6 +3,21 @@ import { Achievement, Project } from '../data';
// Determine API Base URL dynamically from Vite env, fallback to hardcoded if not set // Determine API Base URL dynamically from Vite env, fallback to hardcoded if not set
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.ifuntanhub.dev'; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.ifuntanhub.dev';
const BIDANG_MAP: Record<number, string> = {
1: 'Kecerdasan Buatan',
2: 'Cyber Security',
3: 'Data',
4: 'Software Engineering',
5: 'Competitive Programming',
6: 'IoT',
7: 'Smart City',
8: 'Web3',
9: 'Karya Tulis Ilmiah',
10: 'Robotik',
};
/** /**
* Helper to parse Rank/Juara from Achievement title * Helper to parse Rank/Juara from Achievement title
*/ */
@@ -53,7 +68,7 @@ function extractCompetition(title: string): string {
* Fetch achievements from API * Fetch achievements from API
*/ */
export async function fetchAchievementsFromAPI(): Promise<Achievement[]> { export async function fetchAchievementsFromAPI(): Promise<Achievement[]> {
const response = await fetch(`${API_BASE_URL}/items/prestasi`); const response = await fetch(`${API_BASE_URL}/items/prestasi?fields=*.*`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch achievements: ${response.statusText}`); throw new Error(`Failed to fetch achievements: ${response.statusText}`);
} }
@@ -65,24 +80,39 @@ export async function fetchAchievementsFromAPI(): Promise<Achievement[]> {
.map((item: any) => { .map((item: any) => {
const id = String(item.id); const id = String(item.id);
const title = item.nama_prestasi || 'Untitled Achievement'; const title = item.nama_prestasi || 'Untitled Achievement';
const category = item.bidang || 'Akademik';
let category = 'Umum';
if (item.bidang_id) {
if (typeof item.bidang_id === 'object' && item.bidang_id.nama_bidang) {
category = item.bidang_id.nama_bidang;
} else {
category = BIDANG_MAP[item.bidang_id as number] || 'Umum';
}
}
const rank = extractRank(title); const rank = extractRank(title);
const level = extractLevel(title, category); const level = extractLevel(title, category);
const competition = extractCompetition(title); const competition = extractCompetition(title);
const year = parseInt(item.tahun) || new Date().getFullYear(); const year = parseInt(item.tahun) || new Date().getFullYear();
const team = item.nama_tim || 'Mahasiswa';
const anggota = Array.isArray(item.anggota) ? item.anggota : [];
const team = item.nama_tim || (anggota.length > 0 ? anggota[0].nama : 'Mahasiswa');
// Handle image url mapping for directus assets // Handle image url mapping for directus assets
const image = item.foto let image = `https://picsum.photos/seed/prestasi-${id}/800/600`;
? `${API_BASE_URL}/assets/${item.foto}` if (item.galeri && Array.isArray(item.galeri) && item.galeri.length > 0) {
: `https://picsum.photos/seed/prestasi-${id}/800/600`; const firstImg = item.galeri[0];
const imgId = typeof firstImg === 'string' ? firstImg : (firstImg.directus_files_id || firstImg.id);
if (imgId) image = `${API_BASE_URL}/assets/${imgId}`;
} else if (item.foto) {
image = `${API_BASE_URL}/assets/${item.foto}`;
}
const avatar = (item.nama_tim || item.nama_prestasi || 'IF') const avatar = (item.nama_tim || item.nama_prestasi || 'IF')
.substring(0, 2) .substring(0, 2)
.toUpperCase(); .toUpperCase();
// Create a friendly fallback description since Directus model lacks one const description = item.deskripsi || `${team} berhasil meraih prestasi luar biasa sebagai ${rank} dalam ajang ${competition} tahun ${year} pada bidang ${category.toLowerCase()}.`;
const description = `${team} berhasil meraih prestasi luar biasa sebagai ${rank} dalam ajang ${competition} tahun ${year} pada bidang ${category.toLowerCase()}.`;
return { return {
id, id,
@@ -98,6 +128,7 @@ export async function fetchAchievementsFromAPI(): Promise<Achievement[]> {
generation: item.angkatan ? `Angkatan ${item.angkatan}` : 'Informatika', generation: item.angkatan ? `Angkatan ${item.angkatan}` : 'Informatika',
image, image,
avatar, avatar,
members: anggota.map((m: any) => ({ nama: m.nama, angkatan: m.angkatan ? parseInt(m.angkatan) : undefined })),
}; };
}); });
} }
@@ -106,7 +137,7 @@ export async function fetchAchievementsFromAPI(): Promise<Achievement[]> {
* Fetch projects/portfolio from API * Fetch projects/portfolio from API
*/ */
export async function fetchProjectsFromAPI(): Promise<Project[]> { export async function fetchProjectsFromAPI(): Promise<Project[]> {
const response = await fetch(`${API_BASE_URL}/items/portfolio`); const response = await fetch(`${API_BASE_URL}/items/portfolio?fields=*,bidang.bidang_id.nama_bidang,galeri.*`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch portfolio: ${response.statusText}`); throw new Error(`Failed to fetch portfolio: ${response.statusText}`);
} }
@@ -118,18 +149,43 @@ export async function fetchProjectsFromAPI(): Promise<Project[]> {
.map((item: any) => { .map((item: any) => {
const id = String(item.id); const id = String(item.id);
const title = item.nama_proyek || 'Untitled Project'; const title = item.nama_proyek || 'Untitled Project';
const studentName = item.nama_mahasiswa || 'Mahasiswa';
const anggota = Array.isArray(item.anggota) ? item.anggota : [];
const studentName = item.nama_mahasiswa || (anggota.length > 0 ? anggota[0].nama : 'Mahasiswa');
const year = parseInt(item.tahun) || new Date().getFullYear(); const year = parseInt(item.tahun) || new Date().getFullYear();
const category = item.bidang || 'Software Development';
const description = item.deskripsi || 'Tidak ada deskripsi.'; const description = item.deskripsi || 'Tidak ada deskripsi.';
let categories: string[] = [];
if (item.bidang && Array.isArray(item.bidang)) {
categories = item.bidang.map((b: any) => {
if (b.bidang_id && typeof b.bidang_id === 'object' && b.bidang_id.nama_bidang) {
return b.bidang_id.nama_bidang;
}
if (typeof b.bidang_id === 'number') {
return BIDANG_MAP[b.bidang_id] || 'Umum';
}
return 'Umum';
}).filter(Boolean);
}
if (categories.length === 0) {
categories = ['Umum'];
}
let link = item.url || undefined; let link = item.url || undefined;
if (link && !/^https?:\/\//i.test(link)) { if (link && !/^https?:\/\//i.test(link)) {
link = `https://${link}`; link = `https://${link}`;
} }
const image = item.foto let image = `https://picsum.photos/seed/project-${id}/800/600`;
? `${API_BASE_URL}/assets/${item.foto}` if (item.galeri && Array.isArray(item.galeri) && item.galeri.length > 0) {
: `https://picsum.photos/seed/project-${id}/800/600`; const firstImg = item.galeri[0];
const imgId = typeof firstImg === 'string' ? firstImg : (firstImg.directus_files_id || firstImg.id);
if (imgId) image = `${API_BASE_URL}/assets/${imgId}`;
} else if (item.foto) {
image = `${API_BASE_URL}/assets/${item.foto}`;
}
return { return {
id, id,
@@ -137,10 +193,12 @@ export async function fetchProjectsFromAPI(): Promise<Project[]> {
studentName, studentName,
nim: item.nim || undefined, nim: item.nim || undefined,
year, year,
category, category: categories,
description, description,
image, image,
link, link,
catatan: item.catatan || undefined,
members: anggota.map((m: any) => ({ nama: m.nama, nim: m.nim || undefined })),
}; };
}); });
} }