forked from izu/student-web-if-development-kit
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f22d4f11b | |||
| f410b390e8 | |||
| dfc84bcbe9 | |||
| 141eeeb629 | |||
| 09498578e9 | |||
| 7620308dea | |||
| c5a476c6bd | |||
| fd477e4c22 | |||
| b5fba9238e |
@@ -569,6 +569,7 @@ const translations = {
|
|||||||
kontrib_guideline_3: "Sertakan fakta dan data yang akurat dan dapat diverifikasi.",
|
kontrib_guideline_3: "Sertakan fakta dan data yang akurat dan dapat diverifikasi.",
|
||||||
kontrib_guideline_4: "Hindari plagiarisme. Karya harus orisinal.",
|
kontrib_guideline_4: "Hindari plagiarisme. Karya harus orisinal.",
|
||||||
kontrib_guideline_5: "Tim redaksi berhak menyunting konten untuk keperluan editorial.",
|
kontrib_guideline_5: "Tim redaksi berhak menyunting konten untuk keperluan editorial.",
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
en: {
|
en: {
|
||||||
@@ -1135,6 +1136,7 @@ const translations = {
|
|||||||
kontrib_guideline_3: "Include accurate and verifiable facts and data.",
|
kontrib_guideline_3: "Include accurate and verifiable facts and data.",
|
||||||
kontrib_guideline_4: "Avoid plagiarism. Work must be original.",
|
kontrib_guideline_4: "Avoid plagiarism. Work must be original.",
|
||||||
kontrib_guideline_5: "The editorial team reserves the right to edit content for editorial purposes.",
|
kontrib_guideline_5: "The editorial team reserves the right to edit content for editorial purposes.",
|
||||||
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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>
|
|
||||||
</html>
|
|
||||||
|
Before Width: | Height: | Size: 238 KiB After Width: | Height: | Size: 238 KiB |
+361
-32
@@ -1,5 +1,6 @@
|
|||||||
<!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">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -7,9 +8,12 @@
|
|||||||
<link rel="stylesheet" href="../../assets/global/output.css">
|
<link rel="stylesheet" href="../../assets/global/output.css">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:ital,wght@0,100..900;1,100..900&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap" rel="stylesheet">
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:ital,wght@0,100..900;1,100..900&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap"
|
||||||
|
rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<style>
|
<link rel="stylesheet" href="./style.css">
|
||||||
|
<style>
|
||||||
/* ===== MOBILE NAV STABILITY FIX =====
|
/* ===== MOBILE NAV STABILITY FIX =====
|
||||||
Prevents logo bar from collapsing/shifting on Android when
|
Prevents logo bar from collapsing/shifting on Android when
|
||||||
address bar hides/shows and triggers viewport reflow.
|
address bar hides/shows and triggers viewport reflow.
|
||||||
@@ -293,6 +297,7 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="text-slate-800 antialiased bg-white selection:bg-untan-yellow selection:text-black">
|
<body class="text-slate-800 antialiased bg-white selection:bg-untan-yellow selection:text-black">
|
||||||
|
|
||||||
<a href="#main-content" class="skip-link" data-i18n="skip_to_content">Langsung ke Konten Utama</a>
|
<a href="#main-content" class="skip-link" data-i18n="skip_to_content">Langsung ke Konten Utama</a>
|
||||||
@@ -1139,18 +1144,339 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main id="main-content" class="pt-[88px] lg:pt-[160px] min-h-screen bg-white">
|
<main id="main-content" class="pt-[88px] lg:pt-[160px] min-h-screen bg-white">
|
||||||
<div class="container mx-auto px-4 lg:px-8 py-12">
|
<div class="container mx-auto px-4 lg:px-8 py-12">
|
||||||
<div class="mb-8 pb-8 border-b border-slate-200">
|
<div class="mb-8 pb-8 border-b border-slate-200">
|
||||||
<span class="bg-untan-yellow text-slate-900 text-[10px] font-bold uppercase tracking-widest px-2 py-0.5 inline-block mb-3">Tugas Halaman Statis</span>
|
<span
|
||||||
|
class="bg-untan-yellow text-slate-900 text-[10px] font-bold uppercase tracking-widest px-2 py-0.5 inline-block mb-3">Tugas
|
||||||
|
Halaman Statis</span>
|
||||||
<h1 class="text-3xl lg:text-4xl font-bold text-slate-900 mb-2">Sorotan dan Prestasi Karya Mahasiswa</h1>
|
<h1 class="text-3xl lg:text-4xl font-bold text-slate-900 mb-2">Sorotan dan Prestasi Karya Mahasiswa</h1>
|
||||||
<p class="text-slate-500 text-sm">Kelompok 06 — ACAAB · Informatika UNTAN 2026</p>
|
<p class="text-slate-500 text-sm">Kelompok 06 — ACAAB · Informatika UNTAN 2026
|
||||||
|
</p>
|
||||||
</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">
|
|
||||||
<div id="root" class="not-prose"></div>
|
<!-- ── BREADCRUMB ─────────────────────────────────────────────────────── -->
|
||||||
<script type="module" src="./assets/acaab.js"></script>
|
<nav class="not-prose py-3" aria-label="Breadcrumb">
|
||||||
|
<ol class="flex items-center gap-1 text-[10px] text-slate-500 font-medium tracking-wide uppercase">
|
||||||
|
<li><a href="https://informatika.untan.ac.id" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="hover:text-blue-600 transition-colors">Beranda</a></li>
|
||||||
|
<li><span class="mx-1 text-slate-300" aria-hidden="true">/</span></li>
|
||||||
|
<li><span class="text-slate-800 font-bold" aria-current="page"
|
||||||
|
data-i18n="grp_acaab_hero_badge">Mahasiswa & Prestasi</span></li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- ── HERO ───────────────────────────────────────────────────────────── -->
|
||||||
|
<section id="acaab-hero"
|
||||||
|
class="not-prose acaab-full-bleed bg-untan-navy text-white relative overflow-hidden"
|
||||||
|
style="padding-top:3rem;padding-bottom:0;margin-top:0.5rem;">
|
||||||
|
<!-- Decorative glow -->
|
||||||
|
<div class="absolute top-0 right-0 w-80 h-80 rounded-full pointer-events-none"
|
||||||
|
style="background:rgba(254,180,1,0.08);filter:blur(80px);margin-right:-5rem;margin-top:-5rem;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 lg:px-8">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr;gap:1.5rem;align-items:center;"
|
||||||
|
class="lg-hero-grid">
|
||||||
|
<!-- Left: text -->
|
||||||
|
<div style="padding:1.5rem 0 2rem;max-width:640px;">
|
||||||
|
<span class="acaab-fade-in"
|
||||||
|
style="display:inline-block;background:#feb401;color:#0f172a;font-size:0.65rem;font-weight:700;letter-spacing:0.15em;text-transform:uppercase;padding:0.2rem 0.625rem;margin-bottom:0.75rem;"
|
||||||
|
data-i18n="grp_acaab_hero_badge">MAHASISWA & PRESTASI</span>
|
||||||
|
<h1 class="acaab-fade-in"
|
||||||
|
style="font-size:clamp(1.875rem,3.5vw,2.5rem);font-weight:700;line-height:1.2;letter-spacing:-0.01em;margin:0 0 0.75rem;font-family:'Roboto Condensed',sans-serif;"
|
||||||
|
data-i18n="grp_acaab_hero_title">Sorotan dan Prestasi Karya Mahasiswa</h1>
|
||||||
|
<p class="acaab-fade-in"
|
||||||
|
style="color:rgba(255,255,255,0.7);font-size:0.9375rem;line-height:1.7;margin:0;"
|
||||||
|
data-i18n="grp_acaab_hero_desc">Temukan pencapaian luar biasa mahasiswa Informatika
|
||||||
|
UNTAN.</p>
|
||||||
|
</div>
|
||||||
|
<!-- Right: image -->
|
||||||
|
<div id="acaab-hero-img-wrap"
|
||||||
|
style="display:flex;justify-content:flex-end;align-items:flex-end;position:relative;">
|
||||||
|
<div
|
||||||
|
style="position:absolute;bottom:0;width:280px;height:280px;background:rgba(103,232,249,0.06);border-radius:50%;filter:blur(60px);">
|
||||||
|
</div>
|
||||||
|
<div style="position:relative;">
|
||||||
|
<img src="./hero.png" alt="Mahasiswa Informatika UNTAN"
|
||||||
|
style="position:relative;z-index:10;width:100%;max-width:320px;height:auto;object-fit:contain;opacity:0.95;filter:drop-shadow(0 15px 35px rgba(0,0,0,0.25));transition:transform 0.5s;"
|
||||||
|
onerror="this.style.display='none'">
|
||||||
|
<!-- Gradient fades over image -->
|
||||||
|
<div
|
||||||
|
style="position:absolute;bottom:0;left:0;width:100%;height:8rem;background:linear-gradient(to top,#003150,transparent);z-index:20;pointer-events:none;">
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style="position:absolute;top:0;left:0;height:100%;width:4rem;background:linear-gradient(to right,#003150,transparent);z-index:20;pointer-events:none;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Responsive: 2-column on desktop -->
|
||||||
|
<style>
|
||||||
|
@media (min-width:1024px) {
|
||||||
|
.lg-hero-grid {
|
||||||
|
grid-template-columns: 1fr 1fr !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#acaab-hero-img-wrap img {
|
||||||
|
max-width: 420px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── STATS (yellow card) ────────────────────────────────────────────── -->
|
||||||
|
<div id="acaab-stats" class="not-prose" style="padding:1.5rem 0 0;">
|
||||||
|
<div
|
||||||
|
style="background:#feb401;border-radius:0.75rem;box-shadow:0 10px 30px -10px rgba(253,184,19,0.4);padding:1.25rem 1.5rem 1.5rem;">
|
||||||
|
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;">
|
||||||
|
<!-- Prestasi Block -->
|
||||||
|
<div style="flex:4;min-width:240px;">
|
||||||
|
<div style="background:#003150;border-radius:0.5rem;padding:0.5rem 0.75rem;text-align:center;margin-bottom:1.25rem;">
|
||||||
|
<span style="color:white;font-size:0.875rem;font-weight:800;letter-spacing:0.12em;text-transform:uppercase;"
|
||||||
|
data-i18n="grp_acaab_stats_total_ach">Total Prestasi</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:0.25rem;">
|
||||||
|
<!-- Prestasi Mahasiswa (total) -->
|
||||||
|
<div style="flex:1;text-align:center;padding:0 0.15rem;">
|
||||||
|
<div id="stat-total-ach"
|
||||||
|
style="font-size:clamp(2rem,4.5vw,4rem);font-weight:900;color:#003150;line-height:1;font-family:'Roboto Condensed',sans-serif;">
|
||||||
|
0</div>
|
||||||
|
<div style="font-size:clamp(0.5rem,1.2vw,0.85rem);font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:#003150;margin-top:0.5rem;line-height:1.3;word-break:break-word;">
|
||||||
|
Prestasi<br>Mahasiswa</div>
|
||||||
|
</div>
|
||||||
|
<!-- Internasional -->
|
||||||
|
<div style="flex:1;text-align:center;padding:0 0.15rem;">
|
||||||
|
<div id="stat-intl"
|
||||||
|
style="font-size:clamp(2rem,4.5vw,4rem);font-weight:900;color:#003150;line-height:1;font-family:'Roboto Condensed',sans-serif;">
|
||||||
|
0</div>
|
||||||
|
<div style="font-size:clamp(0.5rem,1.2vw,0.85rem);font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:#003150;margin-top:0.5rem;word-break:break-word;"
|
||||||
|
data-i18n="grp_acaab_stats_intl">Internasional</div>
|
||||||
|
</div>
|
||||||
|
<!-- Nasional -->
|
||||||
|
<div style="flex:1;text-align:center;padding:0 0.15rem;">
|
||||||
|
<div id="stat-national"
|
||||||
|
style="font-size:clamp(2rem,4.5vw,4rem);font-weight:900;color:#003150;line-height:1;font-family:'Roboto Condensed',sans-serif;">
|
||||||
|
0</div>
|
||||||
|
<div style="font-size:clamp(0.5rem,1.2vw,0.85rem);font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:#003150;margin-top:0.5rem;word-break:break-word;"
|
||||||
|
data-i18n="grp_acaab_stats_national">Nasional</div>
|
||||||
|
</div>
|
||||||
|
<!-- Regional -->
|
||||||
|
<div style="flex:1;text-align:center;padding:0 0.15rem;">
|
||||||
|
<div id="stat-regional"
|
||||||
|
style="font-size:clamp(2rem,4.5vw,4rem);font-weight:900;color:#003150;line-height:1;font-family:'Roboto Condensed',sans-serif;">
|
||||||
|
0</div>
|
||||||
|
<div style="font-size:clamp(0.5rem,1.2vw,0.85rem);font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:#003150;margin-top:0.5rem;word-break:break-word;"
|
||||||
|
data-i18n="grp_acaab_stats_regional">Regional</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Proyek Block -->
|
||||||
|
<div style="flex:1;min-width:120px;">
|
||||||
|
<div style="background:#003150;border-radius:0.5rem;padding:0.5rem 0.75rem;text-align:center;margin-bottom:1.25rem;">
|
||||||
|
<span style="color:white;font-size:0.875rem;font-weight:800;letter-spacing:0.12em;text-transform:uppercase;"
|
||||||
|
data-i18n="grp_acaab_stats_total_proj">Total Portofolio</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;justify-content:center;">
|
||||||
|
<!-- Proyek Mahasiswa (total) -->
|
||||||
|
<div style="text-align:center;padding:0 0.15rem;">
|
||||||
|
<div id="stat-total-proj"
|
||||||
|
style="font-size:clamp(2rem,4.5vw,4rem);font-weight:900;color:#003150;line-height:1;font-family:'Roboto Condensed',sans-serif;">
|
||||||
|
0</div>
|
||||||
|
<div style="font-size:clamp(0.5rem,1.2vw,0.85rem);font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:#003150;margin-top:0.5rem;line-height:1.3;word-break:break-word;">
|
||||||
|
Proyek<br>Mahasiswa</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- Data note -->
|
||||||
|
<p id="acaab-data-note"
|
||||||
|
style="font-size:0.6rem;font-weight:600;color:#94a3b8;letter-spacing:0.05em;text-align:right;padding-right:0.25rem;margin:0.5rem 0 0;">
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── ACHIEVEMENT GRID ────────────────────────────────────────────────── -->
|
||||||
|
<section id="acaab-ach-section" class="not-prose" style="padding:2.5rem 0 3rem;">
|
||||||
|
<div style="margin-bottom:2rem;">
|
||||||
|
<h2 style="font-size:clamp(1.5rem,3vw,2.25rem);font-weight:900;color:#003150;margin:0 0 0.5rem;font-family:'Roboto Condensed',sans-serif;"
|
||||||
|
data-i18n="grp_acaab_ach_title">Prestasi Mahasiswa</h2>
|
||||||
|
<p style="color:#64748b;font-size:1rem;max-width:42rem;margin:0;line-height:1.6;"
|
||||||
|
data-i18n="grp_acaab_ach_desc">Daftar pencapaian dan penghargaan yang diraih oleh mahasiswa Informatika UNTAN di berbagai kompetisi.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter panel — gold top border matching original -->
|
||||||
|
<div id="acaab-ach-filters"
|
||||||
|
style="background:rgba(255,255,255,0.8);backdrop-filter:blur(12px);padding:1rem;border-radius:0.5rem;box-shadow:0 1px 3px rgba(0,0,0,0.08);border-top:4px solid #feb401;margin-bottom:2rem;">
|
||||||
|
<!-- Row 1: Label + Reset -->
|
||||||
|
<div
|
||||||
|
style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
|
||||||
|
<div
|
||||||
|
style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;font-weight:700;color:#003150;font-family:'Roboto Condensed',sans-serif;">
|
||||||
|
<i class="fas fa-filter" style="font-size:0.8rem;"></i>
|
||||||
|
<span>Filter Prestasi</span>
|
||||||
|
</div>
|
||||||
|
<button id="acaab-ach-reset"
|
||||||
|
style="display:none;align-items:center;gap:0.25rem;font-size:0.75rem;font-weight:700;color:#64748b;background:none;border:none;cursor:pointer;"
|
||||||
|
data-i18n="btn_reset_filter">
|
||||||
|
<i class="fas fa-times" style="font-size:0.7rem;"></i> Reset Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Row 2: Search -->
|
||||||
|
<div style="position:relative;margin-bottom:0.75rem;">
|
||||||
|
<i class="fas fa-search"
|
||||||
|
style="position:absolute;left:0.75rem;top:50%;transform:translateY(-50%);color:#94a3b8;font-size:0.8rem;pointer-events:none;"></i>
|
||||||
|
<input id="acaab-ach-search" type="search"
|
||||||
|
style="width:100%;background:#f8fafc;color:#334155;font-size:0.875rem;border-radius:0.375rem;padding:0.5rem 0.75rem 0.5rem 2.25rem;border:1px solid #e2e8f0;outline:none;box-sizing:border-box;"
|
||||||
|
placeholder="Cari prestasi..." data-i18n-placeholder="grp_acaab_ach_search">
|
||||||
|
</div>
|
||||||
|
<!-- Row 3: Dropdowns -->
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:0.625rem;">
|
||||||
|
<select id="acaab-ach-level"
|
||||||
|
style="flex:1;min-width:130px;background:#f8fafc;color:#334155;font-size:0.8rem;font-weight:700;border-radius:0.375rem;padding:0.5rem 0.75rem;border:1px solid #e2e8f0;cursor:pointer;"></select>
|
||||||
|
<select id="acaab-ach-cat"
|
||||||
|
style="flex:1;min-width:130px;background:#f8fafc;color:#334155;font-size:0.8rem;font-weight:700;border-radius:0.375rem;padding:0.5rem 0.75rem;border:1px solid #e2e8f0;cursor:pointer;"></select>
|
||||||
|
<select id="acaab-ach-year"
|
||||||
|
style="flex:1;min-width:120px;background:#f8fafc;color:#334155;font-size:0.8rem;font-weight:700;border-radius:0.375rem;padding:0.5rem 0.75rem;border:1px solid #e2e8f0;cursor:pointer;"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Result count -->
|
||||||
|
<p id="acaab-ach-count" style="font-size:0.8rem;color:#64748b;margin-bottom:1rem;"></p>
|
||||||
|
|
||||||
|
<!-- Cards grid -->
|
||||||
|
<div id="acaab-ach-grid"
|
||||||
|
style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:2rem;justify-content:center;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination buttons -->
|
||||||
|
<div style="text-align:center;margin-top:2rem;display:flex;gap:0.75rem;justify-content:center;">
|
||||||
|
<button id="acaab-ach-more"
|
||||||
|
style="display:none;padding:0.625rem 1.5rem;background:#003150;color:white;border:none;border-radius:0.5rem;font-weight:700;font-size:0.875rem;cursor:pointer;"
|
||||||
|
data-i18n="grp_acaab_ach_load_more">Tampilkan Lebih Banyak</button>
|
||||||
|
<button id="acaab-ach-less"
|
||||||
|
style="display:none;padding:0.625rem 1.5rem;background:white;color:#003150;border:1px solid #003150;border-radius:0.5rem;font-weight:700;font-size:0.875rem;cursor:pointer;"
|
||||||
|
data-i18n="grp_acaab_ach_show_less">Tampilkan Lebih Sedikit</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── PROJECT CAROUSEL ───────────────────────────────────────────────── -->
|
||||||
|
<section id="acaab-proj-section" class="not-prose acaab-full-bleed" style="padding:2.5rem 0 3rem;background:#f8fafc;">
|
||||||
|
<div class="container mx-auto px-4 lg:px-8">
|
||||||
|
<!-- Section header row: title + scroll buttons (seperti di React source) -->
|
||||||
|
<div
|
||||||
|
style="display:flex;flex-wrap:wrap;align-items:flex-start;justify-content:space-between;gap:1rem;margin-bottom:1.5rem;">
|
||||||
|
<div style="flex:1;min-width:250px;">
|
||||||
|
<h2 style="font-size:clamp(1.5rem,3vw,2.25rem);font-weight:900;color:#003150;margin:0 0 0.5rem;font-family:'Roboto Condensed',sans-serif;"
|
||||||
|
data-i18n="grp_acaab_proj_title">Top Projects Portofolio</h2>
|
||||||
|
<p style="color:#64748b;font-size:1rem;max-width:42rem;margin:0;line-height:1.6;"
|
||||||
|
data-i18n="grp_acaab_proj_desc">Jelajahi inovasi digital dan aplikasi nyata yang dikembangkan oleh mahasiswa Informatika UNTAN.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter panel -->
|
||||||
|
<div id="acaab-proj-filters"
|
||||||
|
style="background:rgba(255,255,255,0.8);backdrop-filter:blur(12px);padding:1rem;border-radius:0.5rem;box-shadow:0 1px 3px rgba(0,0,0,0.08);border-top:4px solid #feb401;margin-bottom:1.25rem;">
|
||||||
|
<div
|
||||||
|
style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
|
||||||
|
<div
|
||||||
|
style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;font-weight:700;color:#003150;font-family:'Roboto Condensed',sans-serif;">
|
||||||
|
<i class="fas fa-filter" style="font-size:0.8rem;"></i>
|
||||||
|
<span>Filter Portofolio</span>
|
||||||
|
</div>
|
||||||
|
<button id="acaab-proj-reset"
|
||||||
|
style="display:none;align-items:center;gap:0.25rem;font-size:0.75rem;font-weight:700;color:#64748b;background:none;border:none;cursor:pointer;"
|
||||||
|
data-i18n="btn_reset_filter">
|
||||||
|
<i class="fas fa-times" style="font-size:0.7rem;"></i> Reset Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="position:relative;margin-bottom:0.75rem;">
|
||||||
|
<i class="fas fa-search"
|
||||||
|
style="position:absolute;left:0.75rem;top:50%;transform:translateY(-50%);color:#94a3b8;font-size:0.8rem;pointer-events:none;"></i>
|
||||||
|
<input id="acaab-proj-search" type="search"
|
||||||
|
style="width:100%;background:#f8fafc;color:#334155;font-size:0.875rem;border-radius:0.375rem;padding:0.5rem 0.75rem 0.5rem 2.25rem;border:1px solid #e2e8f0;outline:none;box-sizing:border-box;"
|
||||||
|
placeholder="Cari karya..." data-i18n-placeholder="grp_acaab_proj_search">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:0.625rem;">
|
||||||
|
<select id="acaab-proj-cat"
|
||||||
|
style="flex:1;min-width:130px;background:#f8fafc;color:#334155;font-size:0.8rem;font-weight:700;border-radius:0.375rem;padding:0.5rem 0.75rem;border:1px solid #e2e8f0;cursor:pointer;"></select>
|
||||||
|
<select id="acaab-proj-year"
|
||||||
|
style="flex:1;min-width:120px;background:#f8fafc;color:#334155;font-size:0.8rem;font-weight:700;border-radius:0.375rem;padding:0.5rem 0.75rem;border:1px solid #e2e8f0;cursor:pointer;"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scroll hint -->
|
||||||
|
<p style="font-size:0.7rem;color:#94a3b8;text-align:center;margin-bottom:0.75rem;"
|
||||||
|
data-i18n="grp_acaab_proj_scroll_hint">← Geser untuk melihat lebih banyak →</p>
|
||||||
|
|
||||||
|
<!-- Carousel track -->
|
||||||
|
<div id="acaab-proj-track" class="acaab-carousel-track acaab-hide-scrollbar"
|
||||||
|
style="display:flex;gap:1.25rem;overflow-x:auto;padding-bottom:1rem;"></div>
|
||||||
|
|
||||||
|
<!-- Scroll control buttons (bawah) -->
|
||||||
|
<div style="display:flex;justify-content:center;align-items:center;gap:1rem;margin-top:1rem;">
|
||||||
|
<button id="acaab-proj-prev" aria-label="Geser kiri"
|
||||||
|
style="width:3rem;height:3rem;border-radius:50%;border:2px solid rgba(0,49,80,0.2);display:flex;align-items:center;justify-content:center;color:#003150;background:white;cursor:pointer;transition:all 0.2s;">
|
||||||
|
<i class="fas fa-chevron-left" style="font-size:1rem;"></i>
|
||||||
|
</button>
|
||||||
|
<button id="acaab-proj-next" aria-label="Geser kanan"
|
||||||
|
style="width:3rem;height:3rem;border-radius:50%;border:2px solid rgba(0,49,80,0.2);display:flex;align-items:center;justify-content:center;color:#003150;background:white;cursor:pointer;transition:all 0.2s;">
|
||||||
|
<i class="fas fa-chevron-right" style="font-size:1rem;"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Load more -->
|
||||||
|
<div style="text-align:center;margin-top:1.5rem;">
|
||||||
|
<button id="acaab-proj-more"
|
||||||
|
style="display:none;padding:0.625rem 1.5rem;background:#003150;color:white;border:none;border-radius:0.5rem;font-weight:700;font-size:0.875rem;cursor:pointer;"
|
||||||
|
data-i18n="grp_acaab_proj_load_more">Tampilkan Lebih Banyak</button>
|
||||||
|
</div>
|
||||||
|
</div><!-- /inner container -->
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── CTA ────────────────────────────────────────────────────────────── -->
|
||||||
|
<section class="not-prose acaab-full-bleed bg-untan-navy"
|
||||||
|
style="padding:3.5rem 0; margin-bottom: 3rem;">
|
||||||
|
<div class="container mx-auto px-4 lg:px-8">
|
||||||
|
<!-- Inner card: kuning transparan dengan border, rounded-2xl -->
|
||||||
|
<div
|
||||||
|
style="background:rgba(254,180,1,0.08);border:1px solid rgba(254,180,1,0.2);border-radius:1rem;padding:2.5rem 2rem;">
|
||||||
|
<div class="acaab-cta-inner">
|
||||||
|
<!-- Kiri: teks -->
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
style="display:inline-block;background:#feb401;color:#0f172a;font-size:0.625rem;font-weight:700;letter-spacing:0.15em;text-transform:uppercase;padding:0.2rem 0.5rem;border-radius:2px;margin-bottom:1rem;"
|
||||||
|
data-i18n="grp_acaab_cta_badge">HUBUNGI KAMI</span>
|
||||||
|
<h2 style="color:white;font-size:clamp(1.5rem,2.5vw,1.875rem);font-weight:700;margin:0 0 0.75rem;font-family:'Roboto Condensed',sans-serif;line-height:1.2;"
|
||||||
|
data-i18n="grp_acaab_cta_title">Ingin Tahu Lebih Lanjut?</h2>
|
||||||
|
<p style="color:#cbd5e1;font-size:0.9375rem;line-height:1.65;max-width:480px;margin:0;"
|
||||||
|
data-i18n="grp_acaab_cta_desc">Tertarik bergabung dengan Program Studi
|
||||||
|
Informatika UNTAN, mendaftarkan prestasi mahasiswa, atau menjalin kerja sama?
|
||||||
|
Hubungi kami sekarang.</p>
|
||||||
|
</div>
|
||||||
|
<!-- Kanan: tombol WA -->
|
||||||
|
<div style="flex-shrink:0;">
|
||||||
|
<a id="acaab-cta-wa" href="#" target="_blank" rel="noopener noreferrer"
|
||||||
|
style="display:inline-flex;align-items:center;gap:0.625rem;background:#25D366;color:white;padding:0.75rem 1.5rem;border-radius:9999px;font-weight:700;font-size:0.9375rem;text-decoration:none;box-shadow:0 4px 15px rgba(37,211,102,0.25);white-space:nowrap;transition:background 0.2s,transform 0.2s;">
|
||||||
|
<i class="fab fa-whatsapp" style="font-size:1.125rem;"></i>
|
||||||
|
<span data-i18n="grp_acaab_cta_btn">Chat via WhatsApp</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Modal container (diisi oleh main.js) -->
|
||||||
|
<div id="acaab-modal" class="not-prose"></div>
|
||||||
|
|
||||||
<!-- ================= END: BAGIAN YANG DIEDIT KELOMPOK ================= -->
|
<!-- ================= END: BAGIAN YANG DIEDIT KELOMPOK ================= -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1215,8 +1541,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full relative z-0 mt-4 pointer-events-none flex justify-center">
|
<div class="w-full relative z-0 mt-4 pointer-events-none flex justify-center">
|
||||||
<img src="../../assets/global/footer.png" alt="Footer Illustration" class="w-full h-auto object-cover object-bottom"
|
<img src="../../assets/global/footer.png" alt="Footer Illustration"
|
||||||
style="object-position: bottom;">
|
class="w-full h-auto object-cover object-bottom" style="object-position: bottom;">
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
@@ -1312,27 +1638,30 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function () {
|
||||||
const mobileBtn = document.getElementById('mobile-menu-button');
|
const mobileBtn = document.getElementById('mobile-menu-button');
|
||||||
const mobileMenu = document.getElementById('mobile-menu');
|
const mobileMenu = document.getElementById('mobile-menu');
|
||||||
const closeMobileBtn= document.getElementById('close-mobile-menu');
|
const closeMobileBtn = document.getElementById('close-mobile-menu');
|
||||||
if (mobileBtn && mobileMenu) {
|
if (mobileBtn && mobileMenu) {
|
||||||
mobileBtn.addEventListener('click', function() {
|
mobileBtn.addEventListener('click', function () {
|
||||||
const isOpen = !mobileMenu.classList.contains('-translate-x-full');
|
const isOpen = !mobileMenu.classList.contains('-translate-x-full');
|
||||||
mobileMenu.classList.toggle('-translate-x-full');
|
mobileMenu.classList.toggle('-translate-x-full');
|
||||||
mobileBtn.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
|
mobileBtn.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (closeMobileBtn && mobileMenu) {
|
if (closeMobileBtn && mobileMenu) {
|
||||||
closeMobileBtn.addEventListener('click', function() {
|
closeMobileBtn.addEventListener('click', function () {
|
||||||
mobileMenu.classList.add('-translate-x-full');
|
mobileMenu.classList.add('-translate-x-full');
|
||||||
if (mobileBtn) mobileBtn.setAttribute('aria-expanded', 'false');
|
if (mobileBtn) mobileBtn.setAttribute('aria-expanded', 'false');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="../../assets/global/localization.js"></script>
|
<script src="../../assets/global/localization.js"></script>
|
||||||
|
<script src="./localization.js"></script>
|
||||||
|
<script src="./main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* ACAAB localization extension.
|
||||||
|
* Keep group-specific translations outside assets/global/localization.js
|
||||||
|
* to avoid conflicts with the main project.
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function extendAcaabTranslations() {
|
||||||
|
if (typeof translations === 'undefined') return;
|
||||||
|
|
||||||
|
const acaabTranslations = {
|
||||||
|
id: {
|
||||||
|
grp_acaab_hero_badge: "MAHASISWA & PRESTASI",
|
||||||
|
grp_acaab_hero_title: "Sorotan dan Prestasi Karya Mahasiswa",
|
||||||
|
grp_acaab_hero_desc: "Galeri pencapaian, penghargaan, dan portofolio inovasi teknologi yang dikembangkan oleh mahasiswa Program Studi Informatika Universitas Tanjungpura.",
|
||||||
|
grp_acaab_stats_heading: "Pencapaian dalam Angka",
|
||||||
|
grp_acaab_stats_total_ach: "Total Prestasi",
|
||||||
|
grp_acaab_stats_total_proj: "Total Karya",
|
||||||
|
grp_acaab_stats_intl: "Internasional",
|
||||||
|
grp_acaab_stats_national: "Nasional",
|
||||||
|
grp_acaab_stats_regional: "Regional",
|
||||||
|
grp_acaab_stats_data_note: "Data dikumpulkan per",
|
||||||
|
grp_acaab_ach_title: "Prestasi Mahasiswa",
|
||||||
|
grp_acaab_ach_desc: "Daftar pencapaian dan penghargaan yang diraih oleh mahasiswa Informatika UNTAN di berbagai kompetisi.",
|
||||||
|
grp_acaab_ach_search: "Cari prestasi...",
|
||||||
|
grp_acaab_ach_filter_level: "Semua Level",
|
||||||
|
grp_acaab_ach_filter_cat: "Semua Kategori",
|
||||||
|
grp_acaab_ach_filter_year: "Semua Tahun",
|
||||||
|
grp_acaab_ach_showing: "Menampilkan",
|
||||||
|
grp_acaab_ach_results: "prestasi",
|
||||||
|
grp_acaab_ach_load_more: "Tampilkan Lebih Banyak",
|
||||||
|
grp_acaab_ach_show_less: "Tampilkan Lebih Sedikit",
|
||||||
|
grp_acaab_ach_empty: "Tidak ada prestasi yang cocok dengan filter.",
|
||||||
|
grp_acaab_ach_loading: "Memuat data prestasi...",
|
||||||
|
grp_acaab_ach_error: "Gagal memuat data prestasi.",
|
||||||
|
grp_acaab_proj_title: "Top Projects & Portofolio",
|
||||||
|
grp_acaab_proj_desc: "Jelajahi inovasi digital dan aplikasi nyata yang dikembangkan oleh mahasiswa Informatika UNTAN.",
|
||||||
|
grp_acaab_proj_scroll_hint: "Geser untuk melihat lebih banyak",
|
||||||
|
grp_acaab_proj_search: "Cari karya...",
|
||||||
|
grp_acaab_proj_filter_cat: "Semua Kategori",
|
||||||
|
grp_acaab_proj_filter_year: "Semua Tahun",
|
||||||
|
grp_acaab_proj_load_more: "Tampilkan Lebih Banyak",
|
||||||
|
grp_acaab_proj_empty: "Tidak ada karya yang cocok dengan filter.",
|
||||||
|
grp_acaab_proj_loading: "Memuat data karya...",
|
||||||
|
grp_acaab_proj_error: "Gagal memuat data karya.",
|
||||||
|
grp_acaab_modal_team: "Tim / Peserta",
|
||||||
|
grp_acaab_modal_generation: "Angkatan",
|
||||||
|
grp_acaab_modal_members: "Anggota Tim",
|
||||||
|
grp_acaab_modal_visit: "Kunjungi Proyek",
|
||||||
|
grp_acaab_modal_read_more: "Selengkapnya",
|
||||||
|
grp_acaab_modal_no_link: "Link tidak tersedia",
|
||||||
|
grp_acaab_cta_badge: "HUBUNGI KAMI",
|
||||||
|
grp_acaab_cta_title: "Ingin Tahu Lebih Lanjut?",
|
||||||
|
grp_acaab_cta_desc: "Tertarik bergabung dengan Program Studi Informatika UNTAN, mendaftarkan prestasi mahasiswa, atau menjalin kerja sama? Hubungi kami sekarang.",
|
||||||
|
grp_acaab_cta_btn: "Chat via WhatsApp",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
grp_acaab_hero_badge: "STUDENTS & ACHIEVEMENTS",
|
||||||
|
grp_acaab_hero_title: "Student Spotlights & Achievements",
|
||||||
|
grp_acaab_hero_desc: "Gallery of accomplishments, awards, and technology innovation portfolios developed by Informatics students of Tanjungpura University.",
|
||||||
|
grp_acaab_stats_heading: "Achievements by the Numbers",
|
||||||
|
grp_acaab_stats_total_ach: "Total Achievements",
|
||||||
|
grp_acaab_stats_total_proj: "Total Projects",
|
||||||
|
grp_acaab_stats_intl: "International",
|
||||||
|
grp_acaab_stats_national: "National",
|
||||||
|
grp_acaab_stats_regional: "Regional",
|
||||||
|
grp_acaab_stats_data_note: "Data collected as of",
|
||||||
|
grp_acaab_ach_title: "Student Achievements",
|
||||||
|
grp_acaab_ach_desc: "List of awards and accomplishments by Informatics UNTAN students in various competitions.",
|
||||||
|
grp_acaab_ach_search: "Search achievements...",
|
||||||
|
grp_acaab_ach_filter_level: "All Levels",
|
||||||
|
grp_acaab_ach_filter_cat: "All Categories",
|
||||||
|
grp_acaab_ach_filter_year: "All Years",
|
||||||
|
grp_acaab_ach_showing: "Showing",
|
||||||
|
grp_acaab_ach_results: "achievements",
|
||||||
|
grp_acaab_ach_load_more: "Show More",
|
||||||
|
grp_acaab_ach_show_less: "Show Less",
|
||||||
|
grp_acaab_ach_empty: "No achievements match the current filter.",
|
||||||
|
grp_acaab_ach_loading: "Loading achievements...",
|
||||||
|
grp_acaab_ach_error: "Failed to load achievements.",
|
||||||
|
grp_acaab_proj_title: "Top Projects & Portfolios",
|
||||||
|
grp_acaab_proj_desc: "Explore digital innovations and real-world applications developed by Informatics UNTAN students.",
|
||||||
|
grp_acaab_proj_scroll_hint: "Scroll to see more",
|
||||||
|
grp_acaab_proj_search: "Search projects...",
|
||||||
|
grp_acaab_proj_filter_cat: "All Categories",
|
||||||
|
grp_acaab_proj_filter_year: "All Years",
|
||||||
|
grp_acaab_proj_load_more: "Show More",
|
||||||
|
grp_acaab_proj_empty: "No projects match the current filter.",
|
||||||
|
grp_acaab_proj_loading: "Loading projects...",
|
||||||
|
grp_acaab_proj_error: "Failed to load projects.",
|
||||||
|
grp_acaab_modal_team: "Team / Participant",
|
||||||
|
grp_acaab_modal_generation: "Batch Year",
|
||||||
|
grp_acaab_modal_members: "Team Members",
|
||||||
|
grp_acaab_modal_visit: "Visit Project",
|
||||||
|
grp_acaab_modal_read_more: "Read More",
|
||||||
|
grp_acaab_modal_no_link: "Link not available",
|
||||||
|
grp_acaab_cta_badge: "CONTACT US",
|
||||||
|
grp_acaab_cta_title: "Want to Know More?",
|
||||||
|
grp_acaab_cta_desc: "Interested in joining the Informatics Study Program at UNTAN, registering a student achievement, or establishing a partnership? Contact us now.",
|
||||||
|
grp_acaab_cta_btn: "Chat via WhatsApp",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(translations.id, acaabTranslations.id);
|
||||||
|
Object.assign(translations.en, acaabTranslations.en);
|
||||||
|
}());
|
||||||
@@ -0,0 +1,837 @@
|
|||||||
|
/**
|
||||||
|
* main.js — ACAAB Prestasi & Karya Mahasiswa
|
||||||
|
* Vanilla JS: fetch data Directus CMS, render sections, handle interactions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const API_BASE = 'https://api.ifuntanhub.dev';
|
||||||
|
const LANG_KEY = 'if_untan_lang';
|
||||||
|
|
||||||
|
const BIDANG_MAP = {
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Utilities ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function escHtml(str) {
|
||||||
|
if (str === null || str === undefined) return '';
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLang() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(LANG_KEY) ||
|
||||||
|
(navigator.language && navigator.language.startsWith('en') ? 'en' : 'id');
|
||||||
|
} catch (_) { return 'id'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baca dari translations global yang didefinisikan localization.js
|
||||||
|
function t(key) {
|
||||||
|
try {
|
||||||
|
const lang = getLang();
|
||||||
|
if (typeof translations !== 'undefined' && translations[lang] && translations[lang][key]) {
|
||||||
|
return translations[lang][key];
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Data Transformation ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractRank(title) {
|
||||||
|
const m = (title || '').match(/(Juara\s+\d+|Juara\s+Harapan\s*\d*|Top\s+\d+|Finalis|Peringkat\s+\d+|Medali\s+\w+)/i);
|
||||||
|
return m ? m[0] : 'Pemenang';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLevel(title, category) {
|
||||||
|
const text = ((title || '') + ' ' + (category || '')).toLowerCase();
|
||||||
|
if (/internasional|international|global|world|asean|unesco/.test(text)) return 'Internasional';
|
||||||
|
if (/regional|kalbar|kalimantan|provinsi|kota|kabupaten|lokal/.test(text)) return 'Regional';
|
||||||
|
return 'Nasional';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCompetition(title) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageUrl(item, seed) {
|
||||||
|
if (item.galeri && Array.isArray(item.galeri) && item.galeri.length > 0) {
|
||||||
|
const first = item.galeri[0];
|
||||||
|
const imgId = typeof first === 'string' ? first : (first.directus_files_id || first.id);
|
||||||
|
if (imgId) return `${API_BASE}/assets/${imgId}`;
|
||||||
|
}
|
||||||
|
if (item.foto) return `${API_BASE}/assets/${item.foto}`;
|
||||||
|
return `https://picsum.photos/seed/${seed}/800/600`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformAchievement(item) {
|
||||||
|
const id = String(item.id);
|
||||||
|
const title = item.nama_prestasi || 'Untitled Achievement';
|
||||||
|
|
||||||
|
let category = 'Umum';
|
||||||
|
if (item.bidang_id) {
|
||||||
|
category = (typeof item.bidang_id === 'object' && item.bidang_id.nama_bidang)
|
||||||
|
? item.bidang_id.nama_bidang
|
||||||
|
: (BIDANG_MAP[item.bidang_id] || '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');
|
||||||
|
const avatar = (item.nama_tim || title || '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 || '',
|
||||||
|
generation: item.angkatan ? `Angkatan ${item.angkatan}` : 'Informatika',
|
||||||
|
image: getImageUrl(item, `prestasi-${id}`),
|
||||||
|
avatar,
|
||||||
|
members: anggota.map(m => ({ nama: m.nama || '', angkatan: m.angkatan ? parseInt(m.angkatan) : undefined })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformProject(item) {
|
||||||
|
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 = [];
|
||||||
|
if (item.bidang && Array.isArray(item.bidang)) {
|
||||||
|
categories = item.bidang.map(b => {
|
||||||
|
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 || null;
|
||||||
|
if (link && !/^https?:\/\//i.test(link)) link = `https://${link}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id, title, studentName, year, description, categories, link,
|
||||||
|
nim: item.nim || '',
|
||||||
|
image: getImageUrl(item, `project-${id}`),
|
||||||
|
catatan: item.catatan || '',
|
||||||
|
members: anggota.map(m => ({ nama: m.nama || '', nim: m.nim || '' })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── API Fetch ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function fetchAchievements() {
|
||||||
|
const res = await fetch(`${API_BASE}/items/prestasi?fields=*.*`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const { data } = await res.json();
|
||||||
|
return (data || [])
|
||||||
|
.filter(item => item.status === 'published')
|
||||||
|
.map(transformAchievement);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProjects() {
|
||||||
|
const res = await fetch(`${API_BASE}/items/portfolio?fields=*,bidang.bidang_id.nama_bidang,galeri.*`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const { data } = await res.json();
|
||||||
|
return (data || [])
|
||||||
|
.filter(item => item.status === 'published')
|
||||||
|
.map(transformProject);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── App State ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let achievements = [];
|
||||||
|
let projects = [];
|
||||||
|
let achVisible = 3;
|
||||||
|
let projVisible = 6;
|
||||||
|
|
||||||
|
let achFilter = { search: '', level: '', cat: '', year: '' };
|
||||||
|
let projFilter = { search: '', cat: '', year: '' };
|
||||||
|
|
||||||
|
// ─── Init ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
initWaButton();
|
||||||
|
|
||||||
|
// Loading placeholders
|
||||||
|
showSkeleton('acaab-ach-grid', 3);
|
||||||
|
showSkeleton('acaab-proj-track', 4);
|
||||||
|
|
||||||
|
const [achResult, projResult] = await Promise.allSettled([
|
||||||
|
fetchAchievements(),
|
||||||
|
fetchProjects(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (achResult.status === 'fulfilled') {
|
||||||
|
achievements = achResult.value.sort((a, b) => b.year - a.year);
|
||||||
|
} else {
|
||||||
|
console.error('[ACAAB] achievements error:', achResult.reason);
|
||||||
|
showError('acaab-ach-grid', t('grp_acaab_ach_error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projResult.status === 'fulfilled') {
|
||||||
|
projects = projResult.value;
|
||||||
|
} else {
|
||||||
|
console.error('[ACAAB] projects error:', projResult.reason);
|
||||||
|
showError('acaab-proj-track', t('grp_acaab_proj_error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
initStats();
|
||||||
|
initAchievements();
|
||||||
|
initProjects();
|
||||||
|
updateDataNote();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-render saat bahasa berubah dari kit navbar
|
||||||
|
document.addEventListener('languageChanged', () => {
|
||||||
|
updateStats();
|
||||||
|
renderAchievements();
|
||||||
|
buildAchFilterOptions();
|
||||||
|
renderProjects();
|
||||||
|
buildProjFilterOptions();
|
||||||
|
updateWaButton();
|
||||||
|
updateDataNote();
|
||||||
|
updateSearchPlaceholders();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Skeleton / Error ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showSkeleton(containerId, count) {
|
||||||
|
const el = document.getElementById(containerId);
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML = Array(count).fill(0).map(() =>
|
||||||
|
`<div class="acaab-skeleton" style="height:220px;border-radius:0.75rem;"></div>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(containerId, msg) {
|
||||||
|
const el = document.getElementById(containerId);
|
||||||
|
if (el) el.innerHTML = `<p style="color:#ef4444;padding:1rem 0;grid-column:1/-1;">${escHtml(msg)}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Stats ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function initStats() {
|
||||||
|
updateStats();
|
||||||
|
|
||||||
|
const statsEl = document.getElementById('acaab-stats');
|
||||||
|
if (!statsEl) return;
|
||||||
|
|
||||||
|
const intl = achievements.filter(a => a.level === 'Internasional').length;
|
||||||
|
const national = achievements.filter(a => a.level === 'Nasional').length;
|
||||||
|
const regional = achievements.filter(a => a.level === 'Regional').length;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(entries => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (!entry.isIntersecting) return;
|
||||||
|
animateCount('stat-total-ach', achievements.length, 1600);
|
||||||
|
animateCount('stat-total-proj', projects.length, 1600);
|
||||||
|
animateCount('stat-intl', intl, 1200);
|
||||||
|
animateCount('stat-national', national, 1350);
|
||||||
|
animateCount('stat-regional', regional, 1500);
|
||||||
|
observer.disconnect();
|
||||||
|
});
|
||||||
|
}, { threshold: 0.4 });
|
||||||
|
|
||||||
|
observer.observe(statsEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats() {
|
||||||
|
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
|
||||||
|
set('stat-total-ach', achievements.length);
|
||||||
|
set('stat-total-proj', projects.length);
|
||||||
|
set('stat-intl', achievements.filter(a => a.level === 'Internasional').length);
|
||||||
|
set('stat-national', achievements.filter(a => a.level === 'Nasional').length);
|
||||||
|
set('stat-regional', achievements.filter(a => a.level === 'Regional').length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function animateCount(elId, target, duration) {
|
||||||
|
const el = document.getElementById(elId);
|
||||||
|
if (!el) return;
|
||||||
|
const start = performance.now();
|
||||||
|
(function step(now) {
|
||||||
|
const p = Math.min((now - start) / duration, 1);
|
||||||
|
const eased = 1 - Math.pow(1 - p, 4); // easeOutQuart
|
||||||
|
el.textContent = Math.round(target * eased).toLocaleString();
|
||||||
|
if (p < 1) requestAnimationFrame(step);
|
||||||
|
})(start);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDataNote() {
|
||||||
|
const el = document.getElementById('acaab-data-note');
|
||||||
|
if (!el) return;
|
||||||
|
const lang = getLang();
|
||||||
|
const locale = lang === 'en' ? 'en-US' : 'id-ID';
|
||||||
|
const date = new Date().toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
el.textContent = `${t('grp_acaab_stats_data_note')} ${date}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Achievements ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ACH_INIT = 3;
|
||||||
|
const ACH_MORE = 3;
|
||||||
|
|
||||||
|
const LEVEL_CLASS = {
|
||||||
|
Internasional: 'acaab-badge-intl',
|
||||||
|
Nasional: 'acaab-badge-nasional',
|
||||||
|
Regional: 'acaab-badge-regional',
|
||||||
|
};
|
||||||
|
|
||||||
|
function initAchievements() {
|
||||||
|
buildAchFilterOptions();
|
||||||
|
renderAchievements();
|
||||||
|
bindAchEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAchFilterOptions() {
|
||||||
|
const levelSel = document.getElementById('acaab-ach-level');
|
||||||
|
const catSel = document.getElementById('acaab-ach-cat');
|
||||||
|
const yearSel = document.getElementById('acaab-ach-year');
|
||||||
|
if (!levelSel || !catSel || !yearSel) return;
|
||||||
|
|
||||||
|
const levels = ['Internasional', 'Nasional', 'Regional'];
|
||||||
|
const cats = [...new Set(achievements.map(a => a.category))].sort();
|
||||||
|
const years = [...new Set(achievements.map(a => String(a.year)))].filter(Boolean).sort((a, b) => b - a);
|
||||||
|
|
||||||
|
levelSel.innerHTML = `<option value="">${escHtml(t('grp_acaab_ach_filter_level'))}</option>` +
|
||||||
|
levels.map(l => `<option value="${escHtml(l)}"${achFilter.level === l ? ' selected' : ''}>${escHtml(l)}</option>`).join('');
|
||||||
|
catSel.innerHTML = `<option value="">${escHtml(t('grp_acaab_ach_filter_cat'))}</option>` +
|
||||||
|
cats.map(c => `<option value="${escHtml(c)}"${achFilter.cat === c ? ' selected' : ''}>${escHtml(c)}</option>`).join('');
|
||||||
|
yearSel.innerHTML = `<option value="">${escHtml(t('grp_acaab_ach_filter_year'))}</option>` +
|
||||||
|
years.map(y => `<option value="${escHtml(y)}"${achFilter.year === y ? ' selected' : ''}>${escHtml(y)}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterAchievements() {
|
||||||
|
const q = achFilter.search.toLowerCase();
|
||||||
|
return achievements.filter(a => {
|
||||||
|
const matchSearch = !q || [a.title, a.team, a.category, a.competition]
|
||||||
|
.some(s => (s || '').toLowerCase().includes(q));
|
||||||
|
const matchLevel = !achFilter.level || a.level === achFilter.level;
|
||||||
|
const matchCat = !achFilter.cat || a.category === achFilter.cat;
|
||||||
|
const matchYear = !achFilter.year || String(a.year) === achFilter.year;
|
||||||
|
return matchSearch && matchLevel && matchCat && matchYear;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAchievements() {
|
||||||
|
const grid = document.getElementById('acaab-ach-grid');
|
||||||
|
const countEl = document.getElementById('acaab-ach-count');
|
||||||
|
const moreBtn = document.getElementById('acaab-ach-more');
|
||||||
|
const lessBtn = document.getElementById('acaab-ach-less');
|
||||||
|
const resetBtn= document.getElementById('acaab-ach-reset');
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
const filtered = filterAchievements();
|
||||||
|
const visible = filtered.slice(0, achVisible);
|
||||||
|
const hasFilter = achFilter.search || achFilter.level || achFilter.cat || achFilter.year;
|
||||||
|
|
||||||
|
if (countEl) countEl.textContent =
|
||||||
|
`${t('grp_acaab_ach_showing')} ${filtered.length} ${t('grp_acaab_ach_results')}`;
|
||||||
|
if (resetBtn) resetBtn.style.display = hasFilter ? 'flex' : 'none';
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
grid.innerHTML = `<p style="color:#64748b;padding:0.5rem 0;grid-column:1/-1;">${escHtml(t('grp_acaab_ach_empty'))}</p>`;
|
||||||
|
if (moreBtn) moreBtn.style.display = 'none';
|
||||||
|
if (lessBtn) lessBtn.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.innerHTML = visible.map((a, i) => achCardHtml(a, i)).join('');
|
||||||
|
|
||||||
|
if (moreBtn) moreBtn.style.display = achVisible < filtered.length ? 'inline-block' : 'none';
|
||||||
|
if (lessBtn) lessBtn.style.display = achVisible > ACH_INIT ? 'inline-block' : 'none';
|
||||||
|
|
||||||
|
grid.querySelectorAll('.acaab-ach-card').forEach(card => {
|
||||||
|
card.addEventListener('click', (e) => {
|
||||||
|
// Biarkan tombol yang merupakan link tetap berfungsi
|
||||||
|
if (e.target.closest('a')) return;
|
||||||
|
const ach = achievements.find(a => a.id === card.dataset.id);
|
||||||
|
if (ach) openAchModal(ach);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function achCardHtml(a, idx) {
|
||||||
|
return `
|
||||||
|
<article class="acaab-ach-card acaab-fade-in" data-id="${escHtml(a.id)}" style="animation-delay:${idx * 0.05}s;cursor:pointer;">
|
||||||
|
<div class="acaab-ach-top-bar"></div>
|
||||||
|
<!-- Image h-56 (224px) -->
|
||||||
|
<div style="position:relative;height:224px;overflow:hidden;background:#f1f5f9;">
|
||||||
|
<img src="${escHtml(a.image)}" alt="${escHtml(a.title)}" loading="lazy" class="acaab-card-img"
|
||||||
|
style="width:100%;height:100%;object-fit:cover;"
|
||||||
|
onerror="this.src='https://placehold.co/800x600/003150/FDB813?text=Informatika+UNTAN'">
|
||||||
|
<!-- Rank badge top-left: white pill dengan trophy icon -->
|
||||||
|
<div style="position:absolute;top:0.75rem;left:0.75rem;background:rgba(255,255,255,0.95);backdrop-filter:blur(4px);color:#003150;font-weight:700;padding:0.25rem 0.625rem;border-radius:9999px;box-shadow:0 1px 3px rgba(0,0,0,0.15);display:flex;align-items:center;gap:0.375rem;font-size:0.72rem;z-index:2;">
|
||||||
|
<i class="fas fa-trophy" style="color:#feb401;font-size:0.65rem;"></i>
|
||||||
|
${escHtml(a.rank)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Content p-6 -->
|
||||||
|
<div style="padding:1.5rem;display:flex;flex-direction:column;flex-grow:1;">
|
||||||
|
<!-- Category + Year row -->
|
||||||
|
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.75rem;">
|
||||||
|
<span style="color:#1e40af;font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;">${escHtml(a.category)}</span>
|
||||||
|
<span style="color:#94a3b8;font-size:0.72rem;font-weight:500;display:flex;align-items:center;gap:0.25rem;border-left:1px solid #e2e8f0;padding-left:0.625rem;">
|
||||||
|
<i class="fas fa-calendar-alt" style="font-size:0.6rem;"></i> ${escHtml(String(a.year))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Title -->
|
||||||
|
<h3 class="acaab-card-title acaab-line-clamp-2" style="font-size:1.125rem;font-weight:700;color:#003150;margin:0 0 0.75rem;line-height:1.35;font-family:'Roboto Condensed',sans-serif;">${escHtml(a.title)}</h3>
|
||||||
|
<!-- Description 3 lines -->
|
||||||
|
<p class="acaab-line-clamp-3" style="color:#475569;font-size:0.875rem;line-height:1.6;margin:0 0 1.5rem;flex-grow:1;">${escHtml(a.description)}</p>
|
||||||
|
<!-- Bottom -->
|
||||||
|
<div style="border-top:1px solid #f1f5f9;padding-top:1rem;margin-top:auto;">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||||||
|
<!-- Avatar + team + generation -->
|
||||||
|
<div style="display:flex;align-items:center;gap:0.625rem;">
|
||||||
|
<div style="width:2rem;height:2rem;border-radius:0.375rem;background:rgba(0,49,80,0.05);border:1px solid rgba(0,49,80,0.1);display:flex;align-items:center;justify-content:center;color:#003150;font-weight:700;font-size:0.65rem;flex-shrink:0;">${escHtml(a.avatar)}</div>
|
||||||
|
<div>
|
||||||
|
<span style="font-weight:700;color:#1e293b;display:block;font-size:0.8125rem;line-height:1.2;">${escHtml(a.team)}</span>
|
||||||
|
<span style="color:#64748b;display:block;font-size:0.72rem;margin-top:0.1rem;">${escHtml(a.generation)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Level badge -->
|
||||||
|
<span style="background:#f1f5f9;color:#475569;font-size:0.575rem;padding:0.2rem 0.5rem;border-radius:0.25rem;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;white-space:nowrap;">${escHtml(a.level)}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Full-width CTA button -->
|
||||||
|
<button class="acaab-ach-readmore acaab-ach-read-btn" data-id="${escHtml(a.id)}"
|
||||||
|
style="width:100%;padding:0.625rem;border-radius:0.375rem;background:rgba(0,49,80,0.05);color:#003150;font-weight:700;font-size:0.875rem;border:none;cursor:pointer;transition:background 0.2s,color 0.2s;min-height:44px;">
|
||||||
|
${escHtml(t('grp_acaab_modal_read_more'))}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindAchEvents() {
|
||||||
|
const search = document.getElementById('acaab-ach-search');
|
||||||
|
const level = document.getElementById('acaab-ach-level');
|
||||||
|
const cat = document.getElementById('acaab-ach-cat');
|
||||||
|
const year = document.getElementById('acaab-ach-year');
|
||||||
|
const reset = document.getElementById('acaab-ach-reset');
|
||||||
|
const moreBtn = document.getElementById('acaab-ach-more');
|
||||||
|
const lessBtn = document.getElementById('acaab-ach-less');
|
||||||
|
|
||||||
|
const onChange = () => {
|
||||||
|
achFilter.search = search ? search.value.trim() : '';
|
||||||
|
achFilter.level = level ? level.value : '';
|
||||||
|
achFilter.cat = cat ? cat.value : '';
|
||||||
|
achFilter.year = year ? year.value : '';
|
||||||
|
achVisible = ACH_INIT;
|
||||||
|
renderAchievements();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (search) search.addEventListener('input', onChange);
|
||||||
|
if (level) level.addEventListener('change', onChange);
|
||||||
|
if (cat) cat.addEventListener('change', onChange);
|
||||||
|
if (year) year.addEventListener('change', onChange);
|
||||||
|
|
||||||
|
if (reset) reset.addEventListener('click', () => {
|
||||||
|
achFilter = { search: '', level: '', cat: '', year: '' };
|
||||||
|
if (search) search.value = '';
|
||||||
|
if (level) level.value = '';
|
||||||
|
if (cat) cat.value = '';
|
||||||
|
if (year) year.value = '';
|
||||||
|
achVisible = ACH_INIT;
|
||||||
|
renderAchievements();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (moreBtn) moreBtn.addEventListener('click', () => { achVisible += ACH_MORE; renderAchievements(); });
|
||||||
|
if (lessBtn) lessBtn.addEventListener('click', () => {
|
||||||
|
achVisible = ACH_INIT;
|
||||||
|
document.getElementById('acaab-ach-section')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
renderAchievements();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Achievement Modal ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function infoRow(icon, label, value) {
|
||||||
|
if (!value) return '';
|
||||||
|
return `<div style="display:flex;gap:0.625rem;align-items:flex-start;">
|
||||||
|
<span style="color:#feb401;margin-top:0.125rem;flex-shrink:0;"><i class="fas fa-${icon}" style="font-size:0.75rem;"></i></span>
|
||||||
|
<div>
|
||||||
|
<p style="font-size:0.6rem;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:#94a3b8;margin:0 0 0.125rem;line-height:1;">${escHtml(label)}</p>
|
||||||
|
<p style="font-size:0.875rem;font-weight:600;color:#1e293b;margin:0;line-height:1.3;">${escHtml(value)}</p>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function memberItem(nama, sub) {
|
||||||
|
const initial = (nama || '?').charAt(0).toUpperCase();
|
||||||
|
return `<li style="display:flex;align-items:center;gap:0.5rem;background:#f8fafc;padding:0.5rem 0.75rem;border-radius:0.375rem;">
|
||||||
|
<span style="width:1.5rem;height:1.5rem;border-radius:50%;background:rgba(254,180,1,0.2);color:#b45309;display:flex;align-items:center;justify-content:center;font-size:0.72rem;font-weight:700;flex-shrink:0;">${initial}</span>
|
||||||
|
<div style="display:flex;flex-direction:column;">
|
||||||
|
<span style="font-size:0.875rem;font-weight:600;color:#334155;line-height:1.2;">${escHtml(nama)}</span>
|
||||||
|
${sub ? `<span style="font-size:0.65rem;color:#94a3b8;margin-top:0.125rem;">${escHtml(sub)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAchModal(a) {
|
||||||
|
const membersHtml = a.members && a.members.length > 0 ? `
|
||||||
|
<div style="border-top:1px solid #f1f5f9;padding-top:1rem;margin-bottom:1rem;">
|
||||||
|
<p style="font-size:0.6rem;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:#94a3b8;margin:0 0 0.5rem;">Anggota Tim</p>
|
||||||
|
<ul style="list-style:none;margin:0;padding:0;display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;">
|
||||||
|
${a.members.map(m => memberItem(m.nama, m.angkatan ? `Angkatan ${m.angkatan}` : '')).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>` : '';
|
||||||
|
|
||||||
|
renderModal(`
|
||||||
|
<!-- Image header -->
|
||||||
|
<div style="position:relative;border-radius:0.75rem 0.75rem 0 0;background:#0f172a;flex-shrink:0;">
|
||||||
|
<img src="${escHtml(a.image)}" alt="${escHtml(a.title)}" style="width:100%;max-height:450px;object-fit:contain;display:block;"
|
||||||
|
onerror="this.src='https://placehold.co/800x600/0f172a/FDB813?text=Informatika+UNTAN'">
|
||||||
|
<div style="position:absolute;inset:0;background:linear-gradient(to top,rgba(0,0,0,0.8) 0%,rgba(0,0,0,0.1) 40%,transparent 100%);pointer-events:none;"></div>
|
||||||
|
<!-- Close button: white rounded-full -->
|
||||||
|
<button id="acaab-modal-close" aria-label="Tutup"
|
||||||
|
style="position:absolute;top:0.75rem;right:0.75rem;width:2.25rem;height:2.25rem;background:rgba(255,255,255,0.9);backdrop-filter:blur(4px);border:none;border-radius:50%;display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,0.2);">
|
||||||
|
<i class="fas fa-times" style="color:#334155;font-size:0.875rem;"></i>
|
||||||
|
</button>
|
||||||
|
<!-- Two badges bottom-left -->
|
||||||
|
<div style="position:absolute;bottom:1rem;left:1rem;display:flex;gap:0.5rem;flex-wrap:wrap;">
|
||||||
|
<span style="background:#feb401;color:#0f172a;font-size:0.72rem;font-weight:700;padding:0.3rem 0.75rem;border-radius:0.25rem;display:flex;align-items:center;gap:0.375rem;">
|
||||||
|
<i class="fas fa-trophy" style="font-size:0.65rem;"></i>${escHtml(a.rank)}
|
||||||
|
</span>
|
||||||
|
<span style="background:rgba(255,255,255,0.2);backdrop-filter:blur(4px);color:white;font-size:0.72rem;font-weight:700;padding:0.3rem 0.75rem;border-radius:0.25rem;text-transform:uppercase;letter-spacing:0.05em;">
|
||||||
|
${escHtml(a.level)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Content -->
|
||||||
|
<div style="padding:1.5rem;">
|
||||||
|
<span style="color:#1e40af;font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;">${escHtml(a.category)}</span>
|
||||||
|
<h2 style="font-size:1.125rem;font-weight:700;color:#003150;line-height:1.3;margin:0.25rem 0 1.25rem;font-family:'Roboto Condensed',sans-serif;">${escHtml(a.title)}</h2>
|
||||||
|
<!-- Info grid: slate-50 bg -->
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;padding:1rem;background:#f8fafc;border-radius:0.5rem;margin-bottom:1.25rem;">
|
||||||
|
${infoRow('users', t('grp_acaab_modal_team'), a.team)}
|
||||||
|
${a.nim ? infoRow('id-card', 'NIM', a.nim) : ''}
|
||||||
|
${infoRow('user-graduate', t('grp_acaab_modal_generation'), a.generation)}
|
||||||
|
${infoRow('calendar-alt', 'Tahun', String(a.year))}
|
||||||
|
${infoRow('award', 'Kompetisi', a.competition)}
|
||||||
|
${infoRow('tag', 'Bidang', a.category)}
|
||||||
|
</div>
|
||||||
|
${membersHtml}
|
||||||
|
<!-- Description -->
|
||||||
|
<div style="border-top:1px solid #f1f5f9;padding-top:1rem;">
|
||||||
|
<p style="font-size:0.6rem;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:#94a3b8;margin:0 0 0.5rem;">Deskripsi</p>
|
||||||
|
<p style="color:#475569;font-size:0.875rem;line-height:1.65;margin:0;">${escHtml(a.description)}</p>
|
||||||
|
</div>
|
||||||
|
<!-- Close button full-width -->
|
||||||
|
<button id="acaab-modal-close-btn" style="margin-top:1.5rem;width:100%;padding:0.75rem;background:#003150;color:white;font-weight:700;border:none;border-radius:0.5rem;cursor:pointer;font-size:0.875rem;">
|
||||||
|
Tutup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Projects ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PROJ_INIT = 6;
|
||||||
|
const PROJ_MORE = 3;
|
||||||
|
|
||||||
|
function initProjects() {
|
||||||
|
buildProjFilterOptions();
|
||||||
|
renderProjects();
|
||||||
|
bindProjEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProjFilterOptions() {
|
||||||
|
const catSel = document.getElementById('acaab-proj-cat');
|
||||||
|
const yearSel = document.getElementById('acaab-proj-year');
|
||||||
|
if (!catSel || !yearSel) return;
|
||||||
|
|
||||||
|
const cats = [...new Set(projects.flatMap(p => p.categories))].sort();
|
||||||
|
const years = [...new Set(projects.map(p => String(p.year)))].filter(Boolean).sort((a, b) => b - a);
|
||||||
|
|
||||||
|
catSel.innerHTML = `<option value="">${escHtml(t('grp_acaab_proj_filter_cat'))}</option>` +
|
||||||
|
cats.map(c => `<option value="${escHtml(c)}"${projFilter.cat === c ? ' selected' : ''}>${escHtml(c)}</option>`).join('');
|
||||||
|
yearSel.innerHTML = `<option value="">${escHtml(t('grp_acaab_proj_filter_year'))}</option>` +
|
||||||
|
years.map(y => `<option value="${escHtml(y)}"${projFilter.year === y ? ' selected' : ''}>${escHtml(y)}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterProjects() {
|
||||||
|
const q = projFilter.search.toLowerCase();
|
||||||
|
const result = projects.filter(p => {
|
||||||
|
const matchSearch = !q || [p.title, p.studentName, p.description, ...p.categories]
|
||||||
|
.some(s => (s || '').toLowerCase().includes(q));
|
||||||
|
const matchCat = !projFilter.cat || p.categories.includes(projFilter.cat);
|
||||||
|
const matchYear = !projFilter.year || String(p.year) === projFilter.year;
|
||||||
|
return matchSearch && matchCat && matchYear;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort descending by year
|
||||||
|
result.sort((a, b) => (b.year || 0) - (a.year || 0));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProjects() {
|
||||||
|
const track = document.getElementById('acaab-proj-track');
|
||||||
|
const moreBtn = document.getElementById('acaab-proj-more');
|
||||||
|
const resetBtn = document.getElementById('acaab-proj-reset');
|
||||||
|
if (!track) return;
|
||||||
|
|
||||||
|
const filtered = filterProjects();
|
||||||
|
const visible = filtered.slice(0, projVisible);
|
||||||
|
const hasFilter = projFilter.search || projFilter.cat || projFilter.year;
|
||||||
|
|
||||||
|
if (resetBtn) resetBtn.style.display = hasFilter ? 'flex' : 'none';
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
track.innerHTML = `<p style="color:#64748b;padding:0.5rem 0;">${escHtml(t('grp_acaab_proj_empty'))}</p>`;
|
||||||
|
if (moreBtn) moreBtn.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
track.innerHTML = visible.map((p, i) => projCardHtml(p, i)).join('');
|
||||||
|
if (moreBtn) moreBtn.style.display = projVisible < filtered.length ? 'inline-block' : 'none';
|
||||||
|
|
||||||
|
// Responsif: Sembunyikan scroll button & center jika cuma 1 data
|
||||||
|
const prevBtn = document.getElementById('acaab-proj-prev');
|
||||||
|
const nextBtn = document.getElementById('acaab-proj-next');
|
||||||
|
if (prevBtn) prevBtn.style.display = filtered.length <= 1 ? 'none' : 'flex';
|
||||||
|
if (nextBtn) nextBtn.style.display = filtered.length <= 1 ? 'none' : 'flex';
|
||||||
|
track.style.justifyContent = filtered.length === 1 ? 'center' : 'flex-start';
|
||||||
|
|
||||||
|
track.querySelectorAll('.acaab-proj-card-wrap').forEach(card => {
|
||||||
|
card.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('a') && e.target.closest('a').href) return;
|
||||||
|
const proj = projects.find(p => p.id === card.dataset.id);
|
||||||
|
if (proj) openProjModal(proj);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function projCardHtml(p, idx) {
|
||||||
|
const catBadges = p.categories.map(c => `
|
||||||
|
<span style="display:inline-flex;align-items:center;gap:0.2rem;font-size:0.6rem;font-weight:700;color:#feb401;text-transform:uppercase;letter-spacing:0.05em;background:rgba(0,0,0,0.3);padding:0.2rem 0.45rem;border-radius:0.2rem;backdrop-filter:blur(4px);">
|
||||||
|
<i class="fas fa-tag" style="font-size:0.5rem;"></i>${escHtml(c)}
|
||||||
|
</span>`).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="acaab-proj-card acaab-proj-card-wrap acaab-fade-in" data-id="${escHtml(p.id)}" style="animation-delay:${idx * 0.05}s;cursor:pointer;">
|
||||||
|
<div class="acaab-proj-left-bar"></div>
|
||||||
|
<!-- Image h-52 (208px) dengan title overlay -->
|
||||||
|
<div style="height:208px;overflow:hidden;position:relative;">
|
||||||
|
<img src="${escHtml(p.image)}" alt="${escHtml(p.title)}" loading="lazy" class="acaab-card-img"
|
||||||
|
style="width:100%;height:100%;object-fit:cover;"
|
||||||
|
onerror="this.src='https://placehold.co/800x600/003150/FDB813?text=Informatika+UNTAN'">
|
||||||
|
<!-- Gradient overlay: title + categories di dalam gambar -->
|
||||||
|
<div style="position:absolute;inset:0;background:linear-gradient(to top,rgba(0,49,80,0.85),transparent);padding:1.25rem;display:flex;flex-direction:column;justify-content:flex-end;pointer-events:none;">
|
||||||
|
<h4 style="font-size:1rem;font-weight:700;color:white;line-height:1.25;margin:0 0 0.375rem;font-family:'Roboto Condensed',sans-serif;">${escHtml(p.title)}</h4>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:0.3rem;">${catBadges}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Body p-5 -->
|
||||||
|
<div style="padding:1.25rem;display:flex;flex-direction:column;flex-grow:1;">
|
||||||
|
<!-- Meta: student + year -->
|
||||||
|
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:0.625rem;margin-bottom:0.75rem;font-size:0.72rem;color:#64748b;font-weight:500;">
|
||||||
|
${p.studentName ? `<span style="display:flex;align-items:center;gap:0.25rem;"><i class="fas fa-user" style="color:#94a3b8;font-size:0.6rem;"></i>${escHtml(p.studentName)}</span>` : ''}
|
||||||
|
${p.year ? `<span style="display:flex;align-items:center;gap:0.25rem;"><i class="fas fa-calendar-alt" style="color:#94a3b8;font-size:0.6rem;"></i>${escHtml(String(p.year))}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<!-- Description 3 lines -->
|
||||||
|
<p class="acaab-line-clamp-3" style="color:#475569;font-size:0.875rem;line-height:1.6;margin:0 0 1.25rem;flex-grow:1;">${escHtml(p.description)}</p>
|
||||||
|
<!-- Actions -->
|
||||||
|
<div style="display:flex;align-items:center;gap:0.625rem;margin-top:auto;">
|
||||||
|
<button class="acaab-proj-readmore" data-id="${escHtml(p.id)}"
|
||||||
|
style="color:#003150;font-weight:700;font-size:0.875rem;background:none;border:none;cursor:pointer;display:flex;align-items:center;gap:0.375rem;min-height:44px;">
|
||||||
|
${escHtml(t('grp_acaab_modal_read_more'))} <i class="fas fa-arrow-right" style="font-size:0.7rem;"></i>
|
||||||
|
</button>
|
||||||
|
${p.link ? `
|
||||||
|
<a href="${escHtml(p.link)}" target="_blank" rel="noopener noreferrer"
|
||||||
|
style="margin-left:auto;display:flex;align-items:center;gap:0.375rem;font-size:0.72rem;font-weight:700;color:#64748b;border:1px solid #e2e8f0;border-radius:0.375rem;padding:0.375rem 0.625rem;text-decoration:none;min-height:44px;transition:color 0.2s,border-color 0.2s;">
|
||||||
|
<i class="fas fa-external-link-alt" style="font-size:0.65rem;"></i>${escHtml(t('grp_acaab_modal_visit'))}
|
||||||
|
</a>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindProjEvents() {
|
||||||
|
const search = document.getElementById('acaab-proj-search');
|
||||||
|
const cat = document.getElementById('acaab-proj-cat');
|
||||||
|
const year = document.getElementById('acaab-proj-year');
|
||||||
|
const reset = document.getElementById('acaab-proj-reset');
|
||||||
|
const moreBtn = document.getElementById('acaab-proj-more');
|
||||||
|
const prevBtn = document.getElementById('acaab-proj-prev');
|
||||||
|
const nextBtn = document.getElementById('acaab-proj-next');
|
||||||
|
|
||||||
|
const onChange = () => {
|
||||||
|
projFilter.search = search ? search.value.trim() : '';
|
||||||
|
projFilter.cat = cat ? cat.value : '';
|
||||||
|
projFilter.year = year ? year.value : '';
|
||||||
|
projVisible = PROJ_INIT;
|
||||||
|
renderProjects();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (search) search.addEventListener('input', onChange);
|
||||||
|
if (cat) cat.addEventListener('change', onChange);
|
||||||
|
if (year) year.addEventListener('change', onChange);
|
||||||
|
|
||||||
|
if (reset) reset.addEventListener('click', () => {
|
||||||
|
projFilter = { search: '', cat: '', year: '' };
|
||||||
|
if (search) search.value = '';
|
||||||
|
if (cat) cat.value = '';
|
||||||
|
if (year) year.value = '';
|
||||||
|
projVisible = PROJ_INIT;
|
||||||
|
renderProjects();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (moreBtn) moreBtn.addEventListener('click', () => { projVisible += PROJ_MORE; renderProjects(); });
|
||||||
|
|
||||||
|
const track = document.getElementById('acaab-proj-track');
|
||||||
|
if (prevBtn && track) prevBtn.addEventListener('click', () => {
|
||||||
|
const cardW = (track.firstElementChild ? track.firstElementChild.offsetWidth : 300) + 20;
|
||||||
|
track.scrollBy({ left: -cardW, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
if (nextBtn && track) nextBtn.addEventListener('click', () => {
|
||||||
|
const cardW = (track.firstElementChild ? track.firstElementChild.offsetWidth : 300) + 20;
|
||||||
|
track.scrollBy({ left: cardW, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Project Modal ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openProjModal(p) {
|
||||||
|
const membersHtml = p.members && p.members.length > 0 ? `
|
||||||
|
<div style="border-top:1px solid #f1f5f9;padding-top:1rem;margin-bottom:1rem;">
|
||||||
|
<p style="font-size:0.6rem;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:#94a3b8;margin:0 0 0.5rem;">Anggota Tim</p>
|
||||||
|
<ul style="list-style:none;margin:0;padding:0;display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;">
|
||||||
|
${p.members.map(m => memberItem(m.nama, m.nim || '')).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>` : '';
|
||||||
|
|
||||||
|
const catBadgesImg = p.categories.map(c => `
|
||||||
|
<span style="background:#feb401;color:#0f172a;font-size:0.72rem;font-weight:700;padding:0.3rem 0.75rem;border-radius:0.25rem;display:inline-flex;align-items:center;gap:0.375rem;text-transform:uppercase;letter-spacing:0.05em;">
|
||||||
|
<i class="fas fa-tag" style="font-size:0.6rem;"></i>${escHtml(c)}
|
||||||
|
</span>`).join('');
|
||||||
|
|
||||||
|
const linkSection = p.link
|
||||||
|
? `<a href="${escHtml(p.link)}" target="_blank" rel="noopener noreferrer"
|
||||||
|
style="display:flex;align-items:center;justify-content:center;gap:0.5rem;width:100%;padding:0.75rem;background:#003150;color:white;font-weight:700;border-radius:0.5rem;font-size:0.875rem;text-decoration:none;margin-bottom:0.625rem;">
|
||||||
|
<i class="fas fa-external-link-alt" style="font-size:0.8rem;"></i>${escHtml(t('grp_acaab_modal_visit'))}
|
||||||
|
</a>`
|
||||||
|
: `<div style="display:flex;align-items:center;justify-content:center;gap:0.5rem;width:100%;padding:0.75rem;background:#f1f5f9;color:#94a3b8;font-weight:700;border-radius:0.5rem;font-size:0.875rem;margin-bottom:0.625rem;cursor:not-allowed;">
|
||||||
|
<i class="fas fa-file-alt" style="font-size:0.8rem;"></i>${escHtml(t('grp_acaab_modal_no_link'))}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
renderModal(`
|
||||||
|
<!-- Image header -->
|
||||||
|
<div style="position:relative;border-radius:0.75rem 0.75rem 0 0;background:#0f172a;flex-shrink:0;">
|
||||||
|
<img src="${escHtml(p.image)}" alt="${escHtml(p.title)}" style="width:100%;max-height:450px;object-fit:contain;display:block;"
|
||||||
|
onerror="this.src='https://placehold.co/800x600/0f172a/FDB813?text=Informatika+UNTAN'">
|
||||||
|
<div style="position:absolute;inset:0;background:linear-gradient(to top,rgba(0,0,0,0.8) 0%,rgba(0,0,0,0.1) 40%,transparent 100%);pointer-events:none;"></div>
|
||||||
|
<!-- Close button -->
|
||||||
|
<button id="acaab-modal-close" aria-label="Tutup"
|
||||||
|
style="position:absolute;top:0.75rem;right:0.75rem;width:2.25rem;height:2.25rem;background:rgba(255,255,255,0.9);backdrop-filter:blur(4px);border:none;border-radius:50%;display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,0.2);">
|
||||||
|
<i class="fas fa-times" style="color:#334155;font-size:0.875rem;"></i>
|
||||||
|
</button>
|
||||||
|
<!-- Category badges bottom-left -->
|
||||||
|
<div style="position:absolute;bottom:1rem;left:1rem;display:flex;flex-wrap:wrap;gap:0.375rem;">${catBadgesImg}</div>
|
||||||
|
</div>
|
||||||
|
<!-- Content -->
|
||||||
|
<div style="padding:1.5rem;">
|
||||||
|
<h2 style="font-size:1.125rem;font-weight:700;color:#003150;line-height:1.3;margin:0 0 1.25rem;font-family:'Roboto Condensed',sans-serif;">${escHtml(p.title)}</h2>
|
||||||
|
<!-- Info grid -->
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;padding:1rem;background:#f8fafc;border-radius:0.5rem;margin-bottom:1.25rem;">
|
||||||
|
${p.studentName ? infoRow('users', t('grp_acaab_modal_team'), p.studentName) : ''}
|
||||||
|
${p.nim ? infoRow('id-card', 'NIM', p.nim) : ''}
|
||||||
|
${infoRow('calendar-alt', 'Tahun', String(p.year))}
|
||||||
|
${infoRow('tag', 'Bidang', p.categories.join(', '))}
|
||||||
|
</div>
|
||||||
|
${membersHtml}
|
||||||
|
<!-- Description -->
|
||||||
|
<div style="border-top:1px solid #f1f5f9;padding-top:1rem;margin-bottom:1.25rem;">
|
||||||
|
<p style="font-size:0.6rem;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:#94a3b8;margin:0 0 0.5rem;">Deskripsi</p>
|
||||||
|
<p style="color:#475569;font-size:0.875rem;line-height:1.65;margin:0;">${escHtml(p.description)}</p>
|
||||||
|
${p.catatan ? `
|
||||||
|
<div style="margin-top:1rem;padding:0.75rem;background:rgba(239,246,255,0.5);border:1px solid #bfdbfe;border-radius:0.5rem;">
|
||||||
|
<p style="font-size:0.6rem;font-weight:700;text-transform:uppercase;color:#93c5fd;margin:0 0 0.25rem;">Catatan Tambahan</p>
|
||||||
|
<p style="font-size:0.875rem;color:#475569;font-style:italic;margin:0;">${escHtml(p.catatan)}</p>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<!-- Link + close buttons -->
|
||||||
|
${linkSection}
|
||||||
|
<button id="acaab-modal-close-btn" style="width:100%;padding:0.625rem;border:2px solid #e2e8f0;color:#475569;font-weight:700;background:white;border-radius:0.5rem;cursor:pointer;font-size:0.875rem;transition:border-color 0.2s,background 0.2s;">
|
||||||
|
Tutup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Modal shared ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderModal(innerHtml, hasCloseBtn = false) {
|
||||||
|
const container = document.getElementById('acaab-modal');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="acaab-modal-backdrop" id="acaab-modal-bd">
|
||||||
|
<div style="background:white;border-radius:0.75rem;max-width:672px;width:100%;max-height:90vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,0.35);">
|
||||||
|
${innerHtml}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
document.body.classList.add('acaab-modal-open');
|
||||||
|
|
||||||
|
document.getElementById('acaab-modal-close')?.addEventListener('click', closeModal);
|
||||||
|
if (hasCloseBtn) {
|
||||||
|
document.getElementById('acaab-modal-close-btn')?.addEventListener('click', closeModal);
|
||||||
|
}
|
||||||
|
document.getElementById('acaab-modal-bd').addEventListener('click', e => {
|
||||||
|
if (e.target === e.currentTarget) closeModal();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', onEsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
const c = document.getElementById('acaab-modal');
|
||||||
|
if (c) c.innerHTML = '';
|
||||||
|
document.body.classList.remove('acaab-modal-open');
|
||||||
|
document.removeEventListener('keydown', onEsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEsc(e) { if (e.key === 'Escape') closeModal(); }
|
||||||
|
|
||||||
|
// ─── CTA / WhatsApp ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const WA_NUMBER = '6285177655161';
|
||||||
|
|
||||||
|
function initWaButton() { updateWaButton(); }
|
||||||
|
|
||||||
|
function updateWaButton() {
|
||||||
|
const btn = document.getElementById('acaab-cta-wa');
|
||||||
|
if (!btn) return;
|
||||||
|
const msg = getLang() === 'en'
|
||||||
|
? 'Hello! I would like to know more about Informatika UNTAN student achievements and projects.'
|
||||||
|
: 'Halo! Saya ingin mengetahui lebih lanjut tentang prestasi dan karya mahasiswa Informatika UNTAN.';
|
||||||
|
btn.href = `https://wa.me/${WA_NUMBER}?text=${encodeURIComponent(msg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Placeholder sync ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function updateSearchPlaceholders() {
|
||||||
|
const s1 = document.getElementById('acaab-ach-search');
|
||||||
|
const s2 = document.getElementById('acaab-proj-search');
|
||||||
|
if (s1) s1.placeholder = t('grp_acaab_ach_search');
|
||||||
|
if (s2) s2.placeholder = t('grp_acaab_proj_search');
|
||||||
|
}
|
||||||
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,108 +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>
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,374 +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.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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,123 +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 && (
|
|
||||||
<div className="absolute bottom-4 left-4">
|
|
||||||
<span className="bg-primary-gold text-slate-900 text-xs font-bold px-3 py-1.5 rounded flex items-center gap-1.5 uppercase tracking-wide">
|
|
||||||
<Tag size={12} />
|
|
||||||
{project.category}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-6">
|
|
||||||
<h2 className="text-xl font-bold text-primary-navy leading-snug mb-5">{project.title}</h2>
|
|
||||||
|
|
||||||
{/* Info grid */}
|
|
||||||
<div className="grid grid-cols-2 gap-4 p-4 bg-slate-50 rounded-lg mb-5">
|
|
||||||
{project.studentName && (
|
|
||||||
<InfoRow icon={<Users size={14} />} label={t.modal.mahasiswa} value={project.studentName} />
|
|
||||||
)}
|
|
||||||
{project.nim && (
|
|
||||||
<InfoRow icon={<BookOpen size={14} />} label={t.modal.nim} value={project.nim} />
|
|
||||||
)}
|
|
||||||
{project.year && (
|
|
||||||
<InfoRow icon={<Calendar size={14} />} label={t.modal.tahun} value={String(project.year)} />
|
|
||||||
)}
|
|
||||||
{project.category && (
|
|
||||||
<InfoRow icon={<Tag size={14} />} label={t.modal.bidang} value={project.category} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div className="border-t border-slate-100 pt-4 mb-5">
|
|
||||||
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-2">{t.modal.deskripsi}</p>
|
|
||||||
<p className="text-slate-600 text-sm leading-relaxed">{project.description}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Project link */}
|
|
||||||
{project.link ? (
|
|
||||||
<a
|
|
||||||
href={project.link}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center justify-center gap-2 w-full py-3 bg-primary-navy text-white font-bold rounded-lg hover:bg-untan-blue transition-colors duration-200 text-sm mb-3"
|
|
||||||
>
|
|
||||||
<ExternalLink size={16} />
|
|
||||||
{t.project.visitLink}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center gap-2 w-full py-3 bg-slate-100 text-slate-400 font-bold rounded-lg text-sm mb-3 cursor-not-allowed">
|
|
||||||
<FileText size={16} />
|
|
||||||
{t.project.noLink}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="w-full py-2.5 border-2 border-slate-200 text-slate-600 font-bold rounded-lg hover:border-slate-300 hover:bg-slate-50 transition-colors duration-200 text-sm"
|
|
||||||
>
|
|
||||||
{t.modal.close}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 === 'en') return 'en';
|
|
||||||
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,254 +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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Project {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
studentName: string;
|
|
||||||
nim?: string;
|
|
||||||
year: number;
|
|
||||||
category: string;
|
|
||||||
description: string;
|
|
||||||
image: string;
|
|
||||||
link?: 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[] = [];
|
|
||||||
|
|
||||||
@@ -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,146 +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';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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`);
|
|
||||||
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';
|
|
||||||
const category = item.bidang || 'Akademik';
|
|
||||||
const rank = extractRank(title);
|
|
||||||
const level = extractLevel(title, category);
|
|
||||||
const competition = extractCompetition(title);
|
|
||||||
const year = parseInt(item.tahun) || new Date().getFullYear();
|
|
||||||
const team = item.nama_tim || 'Mahasiswa';
|
|
||||||
|
|
||||||
// Handle image url mapping for directus assets
|
|
||||||
const image = item.foto
|
|
||||||
? `${API_BASE_URL}/assets/${item.foto}`
|
|
||||||
: `https://picsum.photos/seed/prestasi-${id}/800/600`;
|
|
||||||
|
|
||||||
const avatar = (item.nama_tim || item.nama_prestasi || 'IF')
|
|
||||||
.substring(0, 2)
|
|
||||||
.toUpperCase();
|
|
||||||
|
|
||||||
// Create a friendly fallback description since Directus model lacks one
|
|
||||||
const description = `${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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch projects/portfolio from API
|
|
||||||
*/
|
|
||||||
export async function fetchProjectsFromAPI(): Promise<Project[]> {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/items/portfolio`);
|
|
||||||
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 studentName = item.nama_mahasiswa || 'Mahasiswa';
|
|
||||||
const year = parseInt(item.tahun) || new Date().getFullYear();
|
|
||||||
const category = item.bidang || 'Software Development';
|
|
||||||
const description = item.deskripsi || 'Tidak ada deskripsi.';
|
|
||||||
let link = item.url || undefined;
|
|
||||||
if (link && !/^https?:\/\//i.test(link)) {
|
|
||||||
link = `https://${link}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const image = item.foto
|
|
||||||
? `${API_BASE_URL}/assets/${item.foto}`
|
|
||||||
: `https://picsum.photos/seed/project-${id}/800/600`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
studentName,
|
|
||||||
nim: item.nim || undefined,
|
|
||||||
year,
|
|
||||||
category,
|
|
||||||
description,
|
|
||||||
image,
|
|
||||||
link,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
/* =====================================================
|
||||||
|
ACAAB – Prestasi & Karya Mahasiswa
|
||||||
|
Custom styles yang tidak dicakup output.css
|
||||||
|
===================================================== */
|
||||||
|
|
||||||
|
/* Cegah horizontal scroll tanpa clip full-bleed */
|
||||||
|
body { overflow-x: hidden; }
|
||||||
|
|
||||||
|
/* --- Full-bleed: section keluar dari container prose --- */
|
||||||
|
.acaab-full-bleed {
|
||||||
|
width: 100vw;
|
||||||
|
margin-left: calc(50% - 50vw);
|
||||||
|
margin-right: calc(50% - 50vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Sembunyikan scrollbar carousel tapi tetap scrollable --- */
|
||||||
|
.acaab-hide-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.acaab-hide-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Card ukuran responsif di carousel --- */
|
||||||
|
.acaab-proj-card {
|
||||||
|
width: 80vw;
|
||||||
|
flex-shrink: 0;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) { .acaab-proj-card { width: 44vw; } }
|
||||||
|
@media (min-width: 1024px) { .acaab-proj-card { width: 30vw; } }
|
||||||
|
|
||||||
|
/* --- Snap scroll carousel --- */
|
||||||
|
.acaab-carousel-track {
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Animasi fade-in untuk cards --- */
|
||||||
|
@keyframes acaab-fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(14px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.acaab-fade-in {
|
||||||
|
animation: acaab-fade-in 0.35s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Badge level warna --- */
|
||||||
|
.acaab-badge-intl { background-color: #ca8a04; color: #fff; }
|
||||||
|
.acaab-badge-nasional { background-color: #1d4ed8; color: #fff; }
|
||||||
|
.acaab-badge-regional { background-color: #64748b; color: #fff; }
|
||||||
|
|
||||||
|
/* --- Skeleton loading --- */
|
||||||
|
.acaab-skeleton {
|
||||||
|
background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: acaab-skeleton-pulse 1.5s infinite;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
@keyframes acaab-skeleton-pulse {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Modal backdrop --- */
|
||||||
|
.acaab-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Scroll lock saat modal terbuka --- */
|
||||||
|
body.acaab-modal-open {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Stats grid: divider vertikal di desktop --- */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.acaab-stats-grid {
|
||||||
|
grid-template-columns: 1fr 1px 1fr 1px 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Line clamp (browser lama) --- */
|
||||||
|
.acaab-line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.acaab-line-clamp-3 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Achievement card --- */
|
||||||
|
.acaab-ach-card {
|
||||||
|
position: relative;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
.acaab-ach-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 10px 25px rgba(0,0,0,0.12), 0 4px 10px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
.acaab-ach-top-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: #003150;
|
||||||
|
z-index: 10;
|
||||||
|
transition: background 0.25s ease;
|
||||||
|
}
|
||||||
|
.acaab-ach-card:hover .acaab-ach-top-bar { background: #feb401; }
|
||||||
|
.acaab-ach-card:hover .acaab-card-title { color: #1e40af; }
|
||||||
|
.acaab-card-title { transition: color 0.2s ease; }
|
||||||
|
.acaab-ach-read-btn:hover { background: #003150 !important; color: white !important; }
|
||||||
|
|
||||||
|
/* --- Project card wrapper --- */
|
||||||
|
.acaab-proj-card-wrap {
|
||||||
|
position: relative;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
.acaab-proj-card-wrap:hover { box-shadow: 0 10px 25px rgba(0,0,0,0.12); }
|
||||||
|
.acaab-proj-left-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 3px; height: 100%;
|
||||||
|
background: #feb401;
|
||||||
|
z-index: 10;
|
||||||
|
transform-origin: bottom;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
.acaab-proj-card-wrap:hover .acaab-proj-left-bar { transform: scaleY(1.08); }
|
||||||
|
.acaab-proj-card-wrap:hover .acaab-card-img { transform: scale(1.05); }
|
||||||
|
|
||||||
|
/* --- Card image zoom --- */
|
||||||
|
.acaab-card-img {
|
||||||
|
transition: transform 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Stats grid dividers responsive --- */
|
||||||
|
.acaab-stat-vdivider { display: none; }
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.acaab-stat-vdivider { display: flex; }
|
||||||
|
.acaab-stat-hdivider { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Filter focus --- */
|
||||||
|
#acaab-ach-filters input:focus,
|
||||||
|
#acaab-ach-filters select:focus,
|
||||||
|
#acaab-proj-filters input:focus,
|
||||||
|
#acaab-proj-filters select:focus {
|
||||||
|
outline: 2px solid #003150;
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: #003150;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- CTA inner layout: stacked mobile → row desktop --- */
|
||||||
|
.acaab-cta-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.acaab-cta-inner {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
text-align: left;
|
||||||
|
gap: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Reduced motion --- */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Modal Responsiveness Overrides --- */
|
||||||
|
.acaab-modal-achievement {
|
||||||
|
max-width: 500px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acaab-modal-project {
|
||||||
|
max-width: 700px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.acaab-modal-achievement, .acaab-modal-project {
|
||||||
|
max-width: 100% !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
border-radius: 1rem 1rem 0 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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