forked from izu/student-web-if-development-kit
375 lines
17 KiB
TypeScript
375 lines
17 KiB
TypeScript
import { useData } from '../context/DataContext';
|
|
import { useRef, useState, useMemo } from 'react';
|
|
import { ArrowRight, ChevronLeft, ChevronRight, ChevronDown, User, Tag, Calendar, ExternalLink, Filter, Search, X } from 'lucide-react';
|
|
import { type Project } from '../data';
|
|
import ProjectModal from './ProjectModal';
|
|
import { useLang } from '../context/LanguageContext';
|
|
|
|
const INITIAL_COUNT = 6;
|
|
const LOAD_MORE_COUNT = 3;
|
|
|
|
|
|
|
|
export default function ProjectCarousel() {
|
|
const { t } = useLang();
|
|
const { projects, loading, error, refetch } = useData();
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
|
const [visibleCount, setVisibleCount] = useState(INITIAL_COUNT);
|
|
|
|
// Filter state
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [filterCategory, setFilterCategory] = useState('');
|
|
const [filterYear, setFilterYear] = useState('');
|
|
|
|
const categories = useMemo(
|
|
() => [...new Set(projects.map((p) => p.category))].sort(),
|
|
[projects]
|
|
);
|
|
|
|
const years = useMemo(
|
|
() => [...new Set(projects.map((p) => p.year))].sort((a: number, b: number) => b - a),
|
|
[projects]
|
|
);
|
|
|
|
// Filter + search logic
|
|
const filtered = useMemo(() => {
|
|
const q = searchQuery.toLowerCase().trim();
|
|
return projects.filter((p) => {
|
|
const matchSearch =
|
|
!q ||
|
|
p.title.toLowerCase().includes(q) ||
|
|
p.studentName.toLowerCase().includes(q) ||
|
|
p.category.toLowerCase().includes(q) ||
|
|
p.description.toLowerCase().includes(q);
|
|
const matchCategory = !filterCategory || p.category === filterCategory;
|
|
const matchYear = !filterYear || String(p.year) === filterYear;
|
|
return matchSearch && matchCategory && matchYear;
|
|
});
|
|
}, [searchQuery, filterCategory, filterYear, projects]);
|
|
|
|
const visibleProjects = filtered.slice(0, visibleCount);
|
|
const hasMore = visibleCount < filtered.length;
|
|
const isFiltered = searchQuery || filterCategory || filterYear;
|
|
|
|
const resetFilters = () => {
|
|
setSearchQuery('');
|
|
setFilterCategory('');
|
|
setFilterYear('');
|
|
setVisibleCount(INITIAL_COUNT);
|
|
};
|
|
|
|
const handleFilterChange = (setter: (v: string) => void) => (v: string) => {
|
|
setter(v);
|
|
setVisibleCount(INITIAL_COUNT);
|
|
};
|
|
|
|
const handleLoadMore = () => setVisibleCount((prev) => prev + LOAD_MORE_COUNT);
|
|
|
|
const handleShowLess = () => {
|
|
setVisibleCount(INITIAL_COUNT);
|
|
document.getElementById('portofolio')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
};
|
|
|
|
const scroll = (direction: 'left' | 'right') => {
|
|
const container = scrollRef.current;
|
|
if (!container) return;
|
|
const cardWidth = container.firstElementChild
|
|
? (container.firstElementChild as HTMLElement).offsetWidth + 24
|
|
: 400;
|
|
container.scrollBy({ left: direction === 'right' ? cardWidth : -cardWidth, behavior: 'smooth' });
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* Project Modal */}
|
|
<ProjectModal project={selectedProject} onClose={() => setSelectedProject(null)} />
|
|
|
|
<section id="portofolio" className="py-16 bg-white relative overflow-hidden border-t border-slate-100">
|
|
<div className="max-w-7xl mx-auto px-4 lg:px-8">
|
|
{/* Header row */}
|
|
<div className="flex items-start justify-between mb-8 gap-4">
|
|
<div>
|
|
<h2 className="text-3xl md:text-4xl font-black font-display text-primary-navy mb-3">
|
|
{t.project.sectionTitle}
|
|
</h2>
|
|
<p className="text-base text-slate-500 font-medium max-w-2xl">
|
|
{t.project.sectionDesc}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Scroll control buttons */}
|
|
<div className="flex items-center gap-2 shrink-0 mt-1">
|
|
<button
|
|
onClick={() => scroll('left')}
|
|
aria-label="Geser kiri"
|
|
className="w-10 h-10 rounded-full border-2 border-primary-navy/20 flex items-center justify-center text-primary-navy hover:bg-primary-navy hover:text-white hover:border-primary-navy transition-all duration-200 shadow-sm"
|
|
>
|
|
<ChevronLeft size={20} />
|
|
</button>
|
|
<button
|
|
onClick={() => scroll('right')}
|
|
aria-label="Geser kanan"
|
|
className="w-10 h-10 rounded-full border-2 border-primary-navy/20 flex items-center justify-center text-primary-navy hover:bg-primary-navy hover:text-white hover:border-primary-navy transition-all duration-200 shadow-sm"
|
|
>
|
|
<ChevronRight size={20} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
{/* Filter + Search Section */}
|
|
<div className="bg-white/80 backdrop-blur-md p-4 rounded-lg shadow-sm border-t-4 border-primary-gold mb-8 space-y-3">
|
|
{/* Row 1: Label + Reset */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-base font-bold font-display text-primary-navy flex items-center gap-2">
|
|
<Filter className="text-primary-navy" size={18} />
|
|
{t.project.filterTitle}
|
|
</div>
|
|
{isFiltered && (
|
|
<button
|
|
onClick={resetFilters}
|
|
className="flex items-center gap-1 text-xs font-bold text-slate-500 hover:text-primary-navy transition-colors"
|
|
>
|
|
<X size={13} />
|
|
{t.project.resetFilter}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Row 2: Search bar */}
|
|
<div className="relative">
|
|
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
|
<input
|
|
id="search-project"
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => { setSearchQuery(e.target.value); setVisibleCount(INITIAL_COUNT); }}
|
|
placeholder={t.project.searchPlaceholder}
|
|
className="w-full bg-slate-50 text-slate-700 text-sm rounded py-2 pl-9 pr-4 border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-gold"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => { setSearchQuery(''); setVisibleCount(INITIAL_COUNT); }}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
|
aria-label="Hapus pencarian"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Row 3: Dropdowns */}
|
|
<div className="flex flex-wrap gap-3">
|
|
{/* Category Filter */}
|
|
<div className="relative flex-1 min-w-[160px]">
|
|
<select
|
|
id="filter-project-category"
|
|
value={filterCategory}
|
|
onChange={(e) => handleFilterChange(setFilterCategory)(e.target.value)}
|
|
className="w-full bg-slate-50 text-slate-700 font-bold text-sm rounded py-2 pl-4 pr-9 shadow-sm focus:ring-2 focus:ring-primary-gold appearance-none cursor-pointer border border-slate-200"
|
|
>
|
|
<option value="">{t.project.allField}</option>
|
|
{categories.map((cat) => (
|
|
<option key={cat} value={cat}>{cat}</option>
|
|
))}
|
|
</select>
|
|
<ChevronDown size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
|
</div>
|
|
|
|
{/* Year Filter */}
|
|
<div className="relative flex-1 min-w-[120px]">
|
|
<select
|
|
id="filter-project-year"
|
|
value={filterYear}
|
|
onChange={(e) => handleFilterChange(setFilterYear)(e.target.value)}
|
|
className="w-full bg-slate-50 text-slate-700 font-bold text-sm rounded py-2 pl-4 pr-9 shadow-sm focus:ring-2 focus:ring-primary-gold appearance-none cursor-pointer border border-slate-200"
|
|
>
|
|
<option value="">{t.project.allYear}</option>
|
|
{years.map((yr) => (
|
|
<option key={yr} value={String(yr)}>{yr}</option>
|
|
))}
|
|
</select>
|
|
<ChevronDown size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Results count */}
|
|
{isFiltered && (
|
|
<p className="text-sm text-slate-500 font-medium mb-6">
|
|
{t.project.showing}{' '}
|
|
<span className="font-bold text-primary-navy">{filtered.length}</span>{' '}
|
|
{t.project.matching}
|
|
</p>
|
|
)}
|
|
|
|
{/* Cards */}
|
|
{loading ? (
|
|
<div className="flex gap-6 overflow-x-auto pb-10 hide-scrollbar">
|
|
{[1, 2, 3].map((n) => (
|
|
<div
|
|
key={n}
|
|
className="snap-start shrink-0 proj-card bg-white rounded-lg border border-slate-200 overflow-hidden shadow-md animate-pulse flex flex-col"
|
|
>
|
|
<div className="h-52 bg-slate-200 w-full" />
|
|
<div className="p-5 space-y-4 flex-grow flex flex-col justify-between">
|
|
<div className="space-y-2">
|
|
<div className="h-4 bg-slate-200 rounded w-1/4" />
|
|
<div className="h-5 bg-slate-200 rounded w-3/4" />
|
|
<div className="h-4 bg-slate-200 rounded w-5/6" />
|
|
<div className="h-4 bg-slate-200 rounded w-2/3" />
|
|
</div>
|
|
<div className="h-10 bg-slate-200 rounded w-1/3 mt-4" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : error ? (
|
|
<div className="text-center py-10 text-red-500 w-full">
|
|
<p className="text-lg font-semibold">Gagal memuat portofolio</p>
|
|
<p className="text-sm mt-1">{error}</p>
|
|
<button
|
|
onClick={refetch}
|
|
className="mt-4 px-4 py-2 bg-primary-navy text-white font-bold rounded hover:bg-opacity-95 transition-all text-sm"
|
|
>
|
|
Coba Lagi
|
|
</button>
|
|
</div>
|
|
) : visibleProjects.length === 0 ? (
|
|
<div className="text-center py-20 text-slate-400">
|
|
<p className="text-lg font-semibold">{t.project.noData}</p>
|
|
<p className="text-sm mt-1">{t.project.noDataSub}</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Scrollable cards */}
|
|
<div
|
|
ref={scrollRef}
|
|
className="flex gap-6 overflow-x-auto pb-10 hide-scrollbar snap-x snap-mandatory"
|
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
|
>
|
|
{visibleProjects.map((project) => (
|
|
<div
|
|
key={project.id}
|
|
className="snap-start shrink-0 proj-card bg-white rounded-lg overflow-hidden shadow-md border border-slate-200 group flex flex-col relative"
|
|
>
|
|
<div className="absolute top-0 left-0 w-1 h-full bg-primary-gold z-10 transition-transform origin-bottom group-hover:scale-y-110" />
|
|
|
|
{/* Image */}
|
|
<div className="h-52 overflow-hidden relative">
|
|
<img
|
|
src={project.image}
|
|
alt={project.title}
|
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
|
referrerPolicy="no-referrer"
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).src = 'https://placehold.co/800x600/003150/FDB813?text=Informatika+UNTAN';
|
|
}}
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-primary-navy/80 to-transparent p-5 flex flex-col justify-end">
|
|
<h4 className="text-lg font-bold font-display text-white leading-snug mb-1">
|
|
{project.title}
|
|
</h4>
|
|
{project.category && (
|
|
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-primary-gold uppercase tracking-wider">
|
|
<Tag size={10} />
|
|
{project.category}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="p-5 flex flex-col flex-grow">
|
|
{/* Meta row */}
|
|
<div className="flex flex-wrap items-center gap-3 mb-3 text-xs text-slate-500 font-medium">
|
|
{project.studentName && (
|
|
<span className="flex items-center gap-1">
|
|
<User size={11} className="text-slate-400" />
|
|
{project.studentName}
|
|
</span>
|
|
)}
|
|
{project.year && (
|
|
<span className="flex items-center gap-1">
|
|
<Calendar size={11} className="text-slate-400" />
|
|
{project.year}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-slate-600 text-sm leading-relaxed mb-5 flex-grow line-clamp-3">
|
|
{project.description}
|
|
</p>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-3 mt-auto">
|
|
<button
|
|
onClick={() => setSelectedProject(project)}
|
|
className="text-primary-navy font-bold text-sm tracking-wide hover:text-untan-blue flex items-center gap-2 group/btn"
|
|
style={{ minHeight: '44px' }}
|
|
>
|
|
{t.project.readMore}
|
|
<ArrowRight size={15} className="group-hover/btn:translate-x-1 transition-transform" />
|
|
</button>
|
|
|
|
{project.link && (
|
|
<a
|
|
href={project.link}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="ml-auto flex items-center gap-1.5 text-xs font-bold text-slate-500 hover:text-primary-navy transition-colors border border-slate-200 hover:border-primary-navy/30 rounded px-3 py-2"
|
|
style={{ minHeight: '44px' }}
|
|
>
|
|
<ExternalLink size={13} />
|
|
{t.project.visitLink}
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Scroll hint */}
|
|
<p className="text-center text-xs text-slate-400 font-medium mt-2 select-none">
|
|
{t.project.scrollHint}
|
|
</p>
|
|
|
|
{/* Load More / Show Less */}
|
|
<div className="mt-10 flex flex-wrap items-center justify-center gap-6">
|
|
{hasMore && (
|
|
<button
|
|
id="load-more-project"
|
|
onClick={handleLoadMore}
|
|
className="text-primary-navy font-black text-base underline underline-offset-[10px] decoration-primary-gold decoration-4 hover:text-primary-gold transition-colors flex items-center gap-2"
|
|
>
|
|
{t.project.loadMore}
|
|
<ChevronDown size={18} />
|
|
</button>
|
|
)}
|
|
|
|
{!hasMore && visibleProjects.length > 0 && filtered.length > INITIAL_COUNT && (
|
|
<p className="text-slate-400 text-sm font-medium">
|
|
{t.project.showing} {filtered.length} {t.project.allShown}
|
|
</p>
|
|
)}
|
|
|
|
{visibleCount > INITIAL_COUNT && visibleProjects.length > 0 && (
|
|
<button
|
|
onClick={handleShowLess}
|
|
className="text-slate-400 font-bold text-sm underline underline-offset-4 decoration-slate-300 decoration-2 hover:text-slate-600 transition-colors flex items-center gap-1.5"
|
|
>
|
|
<ChevronDown size={16} className="rotate-180" />
|
|
{t.project.showLess}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</>
|
|
);
|
|
}
|