forked from izu/student-web-if-development-kit
merge upstream
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
# 🎓 Portal Informasi Penerimaan Mahasiswa Baru (PMB)
|
||||
**Program Studi Informatika - Universitas Tanjungpura (UNTAN)**
|
||||
|
||||

|
||||
*Pratinjau Antarmuka Halaman PMB Informatika UNTAN oleh Kelompok 02*
|
||||
|
||||
---
|
||||
|
||||
## 💡 Pengantar (Project Overview)
|
||||
|
||||
### 📌 Gambaran Umum
|
||||
Proyek ini merupakan halaman web statis yang dikembangkan untuk menyediakan informasi penerimaan mahasiswa baru secara terpusat, terstruktur, dan mudah diakses. Halaman ini bertujuan membantu calon mahasiswa dalam memahami berbagai jalur seleksi serta informasi terkait secara menyeluruh.
|
||||
|
||||
### 🎯 Fitur Utama
|
||||
|
||||
Penyajian informasi terpusat untuk seluruh jalur penerimaan:
|
||||
1. SNBP
|
||||
2. SNBT
|
||||
3. Mandiri-UNTAN
|
||||
4. SMMPTN-Barat
|
||||
|
||||
Informasi detail setiap jalur:
|
||||
1. Persyaratan
|
||||
2. Mekanisme seleksi
|
||||
3. Jadwal
|
||||
4. Biaya pendidikan
|
||||
5. Informasi beasiswa
|
||||
6. FAQ (Frequently Asked Questions)
|
||||
7. Tautan ke situs resmi dan sumber informasi terkait
|
||||
8. Tampilan responsif untuk desktop dan perangkat mobile
|
||||
|
||||
---
|
||||
|
||||
## ❓ Tanya Jawab Seputar Proyek (Project Q&A)
|
||||
|
||||
**Q: Apa fokus utama dari rancangan website ini?**
|
||||
A: Website ini dirancang sebagai pusat informasi terpadu yang merangkum empat jalur seleksi masuk UNTAN (SNBP, SNBT, SMMPTN-Barat, dan Mandiri). Pengunjung dapat melihat persyaratan, mekanisme, jadwal, rincian biaya, hingga peluang beasiswa dalam satu tampilan interaktif tanpa harus berpindah halaman.
|
||||
|
||||
**Q: Mengapa dibangun sepenuhnya sebagai halaman statis?**
|
||||
A: Pendekatan statis (HTML, CSS, Vanilla JS) menawarkan tiga keuntungan utama: **Kecepatan** muat yang luar biasa, **Keamanan** tingkat tinggi (karena tidak ada akses *database* langsung dari luar), dan kemudahan **Deployment** di hampir semua layanan *hosting*.
|
||||
|
||||
**Q: Bagaimana sistem dua bahasa (Bilingual) bekerja di website ini?**
|
||||
A: Kami menggunakan *script* kustom (`localization.js`) yang mendeteksi bahasa bawaan dari peramban pengguna (`navigator.language`). Pengguna juga dapat mengubah bahasa secara manual (ID/EN), dan pilihan tersebut akan disimpan di dalam `localStorage` sehingga preferensi tidak hilang saat halaman di-muat ulang.
|
||||
|
||||
**Q: Apakah visualisasi data di website ini bersifat statis (gambar)?**
|
||||
A: Tidak. Kami mengimplementasikan **Chart.js** untuk merender grafik perbandingan antara "Jumlah Peminat" dan "Daya Tampung" secara dinamis dan interaktif.
|
||||
|
||||
**Q: Bagaimana tingkat responsivitas website ini di perangkat seluler?**
|
||||
A: Sangat optimal. Kami menggunakan pendekatan tata letak *Grid* dan *Flexbox* mutakhir. Untuk navigasi kartu jalur masuk, kami bahkan mengintegrasikan *Horizontal Drag Slider* yang ramah sentuhan (touch-friendly) khusus untuk perangkat *mobile* dan tablet.
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Navigasi & Antarmuka
|
||||
|
||||
1. Menggunakan pendekatan single-page dengan smooth scrolling
|
||||
2. Navigasi berbasis tab untuk pemilihan jalur seleksi
|
||||
3. Komponen accordion untuk FAQ dan persyaratan
|
||||
4. Layout berbasis card untuk penyajian informasi ringkas
|
||||
|
||||
---
|
||||
|
||||
## 📎 Sumber Eksternal
|
||||
Sistem menyediakan tautan menuju berbagai sumber resmi, seperti:
|
||||
|
||||
1. Situs resmi SNPMB
|
||||
2. Situs resmi Mandiri-UNTAN
|
||||
3. Situs resmi SMMPTN-Barat
|
||||
4. Portal informasi beasiswa
|
||||
5. Media sosial resmi terkait
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Tumpukan Teknologi (Tech Stack)
|
||||
|
||||
| Kategori | Teknologi | Kegunaan dalam Proyek |
|
||||
| :--- | :--- | :--- |
|
||||
| **Inti (Core)** | HTML5, CSS3, Vanilla JS | Fundamental pembentuk struktur, gaya, dan logika halaman. |
|
||||
| **Visualisasi** | Chart.js 4.4 | Merender grafik batang peminat & daya tampung. |
|
||||
| **Animasi** | AOS (Animate On Scroll) | Memberikan efek transisi halus saat elemen masuk ke *viewport*. |
|
||||
| **Ikonografi** | FontAwesome 6.5 | Menyediakan aset ikon modern dan ringan berskala vektor. |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Panduan Deployment (Hosting Guide)
|
||||
Sifat proyek yang 100% statis membuatnya sangat mudah untuk di-deploy (di-online-kan). Pilih salah satu platform favorit Anda di bawah ini:
|
||||
|
||||
### Opsi 1: Vercel (Paling Direkomendasikan & Cepat)
|
||||
|
||||
1. Push proyek ke repositori GitHub, GitLab, atau Bitbucket.
|
||||
|
||||
2. Buat akun dan masuk ke Vercel.
|
||||
|
||||
3. Klik tombol Add New Project dan import repositori Anda.
|
||||
|
||||
4. Pada bagian Framework Preset, pilih Other.
|
||||
|
||||
5. Kosongkan bagian Build Command dan Output Directory.
|
||||
|
||||
6. Klik Deploy. Website Anda akan aktif dalam kurang dari 30 detik.
|
||||
|
||||
### Opsi 2: Coolify (Self-Hosted)
|
||||
|
||||
1. Masuk ke Dashboard Coolify server Anda.
|
||||
|
||||
2. Buka menu proyek lalu tambahkan resource baru (+ Add).
|
||||
|
||||
3. Pilih opsi Application dan hubungkan repositori Git Anda.
|
||||
|
||||
4. Coolify secara otomatis akan menggunakan Nixpacks untuk mendeteksi Static/HTML Site.
|
||||
|
||||
5. Kosongkan Build Command dan Install Command.
|
||||
|
||||
6. Klik Deploy.
|
||||
|
||||
### Opsi 3: GitHub Pages (Paling Praktis)
|
||||
|
||||
1. Pastikan kode sudah di-push ke repositori GitHub.
|
||||
|
||||
2. Masuk ke halaman repositori Anda, klik tab Settings.
|
||||
|
||||
3. Di panel sebelah kiri, pilih menu Pages.
|
||||
|
||||
4. Pada bagian Source, pilih Deploy from a branch.
|
||||
|
||||
5. Pilih branch utama dan pastikan foldernya diatur ke / (root).
|
||||
|
||||
6. Klik Save.
|
||||
|
||||
### Opsi 4: Netlify (Sistem Drag & Drop)
|
||||
|
||||
1. Masuk ke Netlify.
|
||||
|
||||
2. Masuk ke menu Sites -> Klik Add new site.
|
||||
|
||||
3. Kita bisa memilih Import an existing project (lewat Git) atau Deploy manually (cukup seret dan lepas folder proyek ke browser).
|
||||
|
||||
4. Jika lewat Git, biarkan Build command dan Publish directory kosong.
|
||||
|
||||
5. Klik Deploy site.
|
||||
|
||||
---
|
||||
|
||||
## 👥 Tim Pengembang (Credits)
|
||||
Disusun dan dikembangkan oleh Kelompok 02 untuk memenuhi standar tugas perancangan antarmuka Halaman Statis.
|
||||
|
||||
Nama Kelompok : Habis Ini Seminar
|
||||
|
||||
---
|
||||
|
||||
## 📂 Struktur Direktori (Folder Structure)
|
||||
|
||||
Pastikan struktur direktori Anda sesuai sebelum menjalankan atau men-deploy proyek ini:
|
||||
|
||||
```text
|
||||
📦 project-root/
|
||||
┣ 📜 localization.js # Script logika terjemahan & State Management
|
||||
┃ 🖼️ logo.png # Logo Universitas/Prodi
|
||||
┃ 🖼️ footer.png # Aset ornamen footer
|
||||
┣ 📜 index.html # Halaman utama aplikasi (Entry point)
|
||||
┣ 📜 style.css # Berkas tata letak dan desain (Styling)
|
||||
┣ 🖼️ Screenshot.png # Gambar pratinjau halaman
|
||||
┗ 📜 README.md # Dokumentasi proyek
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
+1336
-1273
File diff suppressed because it is too large
Load Diff
+1729
-25
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,123 @@
|
||||
/* ===========================================
|
||||
Base / Reset — scoped under .tapops-doc-root
|
||||
Prevents contaminating Tailwind nav/footer.
|
||||
=========================================== */
|
||||
|
||||
.tapops-doc-root *,
|
||||
.tapops-doc-root *::before,
|
||||
.tapops-doc-root *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Font is set on the root only (inherited), NOT on `*`.
|
||||
A universal `.tapops-doc-root *` rule would tie Font Awesome's
|
||||
`.fas` font-family in specificity and—loading later—override it,
|
||||
blanking every icon. Inheritance loses to FA's explicit rule, so
|
||||
icons render while normal text still gets Roboto. */
|
||||
.tapops-doc-root {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
color: var(--text-dark);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Form controls don't inherit font-family by default — opt them in.
|
||||
These elements never hold Font Awesome glyphs, so this is icon-safe. */
|
||||
.tapops-doc-root input,
|
||||
.tapops-doc-root select,
|
||||
.tapops-doc-root textarea,
|
||||
.tapops-doc-root button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.tapops-doc-root a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.tapops-doc-root img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* --- Shared Section Styles --- */
|
||||
|
||||
.tapops-doc-root .section-header {
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
.tapops-doc-root .section-tag {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 2px;
|
||||
background-color: var(--secondary-gold);
|
||||
color: var(--primary-blue);
|
||||
display: inline-block;
|
||||
padding: 6px 14px;
|
||||
text-transform: uppercase;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.tapops-doc-root .section-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--primary-blue);
|
||||
letter-spacing: -1px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
/* --- Shared Button Styles --- */
|
||||
|
||||
.tapops-doc-root .btn-join,
|
||||
.tapops-doc-root .btn-outline {
|
||||
padding: 17px 30px;
|
||||
font-weight: 800;
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 1px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tapops-doc-root .btn-join {
|
||||
background-color: var(--secondary-gold);
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.tapops-doc-root .btn-outline {
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tapops-doc-root .btn-join:hover,
|
||||
.tapops-doc-root .btn-outline:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.tapops-doc-root .btn-join:hover {
|
||||
background-color: #fff;
|
||||
box-shadow: 0 15px 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.tapops-doc-root .btn-outline:hover {
|
||||
border-color: var(--secondary-gold);
|
||||
color: var(--secondary-gold);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tapops-doc-root .btn-join,
|
||||
.tapops-doc-root .btn-outline {
|
||||
padding: 14px 24px;
|
||||
font-size: 0.82rem;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.tapops-doc-root .btn-join,
|
||||
.tapops-doc-root .btn-outline {
|
||||
padding: 12px 20px;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import url("repository/index.css");
|
||||
@@ -0,0 +1,276 @@
|
||||
/* ===========================================
|
||||
Hero Section (Centered & Immersive) — Tapops
|
||||
=========================================== */
|
||||
|
||||
.research-hero.immersive-hero {
|
||||
background: radial-gradient(circle at center, #003150 0%, #001e35 100%);
|
||||
color: white;
|
||||
padding: 160px 5% 180px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding-bottom: 200px;
|
||||
}
|
||||
|
||||
.research-hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 800px;
|
||||
height: 800px;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
background: conic-gradient(from 180deg at 50% 50%,
|
||||
transparent 0deg,
|
||||
rgba(254, 180, 1, 0.15) 180deg,
|
||||
transparent 360deg);
|
||||
|
||||
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #fff 0);
|
||||
mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #fff 0);
|
||||
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
animation: rotate-orbit 30s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate-orbit {
|
||||
from {
|
||||
transform: translate(-50%, -50%) rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translate(-50%, -50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.hero-container-centered {
|
||||
max-width: 900px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.hero-kicker {
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 4px;
|
||||
font-weight: 800;
|
||||
color: var(--secondary-gold);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 24px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 4.8rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -3px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.hero-tagline {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.6;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 45px;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* --- Floating Insight Bar --- */
|
||||
|
||||
.hero-floating-stat-bar {
|
||||
position: absolute;
|
||||
bottom: 50px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 25px 60px;
|
||||
border-radius: 4px;
|
||||
z-index: 3;
|
||||
gap: 40px;
|
||||
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.3);
|
||||
max-width: calc(100% - 32px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.stat-bar-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stat-bar-value {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 800;
|
||||
color: var(--secondary-gold);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.stat-bar-label {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
opacity: 0.6;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stat-bar-divider {
|
||||
width: 1px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Hero Responsive
|
||||
=========================================== */
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.research-hero.immersive-hero {
|
||||
padding: 120px 5% 180px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 3.5rem;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
|
||||
.hero-tagline {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.hero-floating-stat-bar {
|
||||
padding: 20px 32px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.stat-bar-value {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.research-hero.immersive-hero {
|
||||
padding: 100px 16px 130px;
|
||||
}
|
||||
|
||||
.hero-kicker {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 2.5px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 2.2rem;
|
||||
letter-spacing: -1px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.hero-tagline {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-floating-stat-bar {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
padding: 18px 24px;
|
||||
gap: 20px;
|
||||
bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-bar-value {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.stat-bar-label {
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.stat-bar-divider {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.research-hero::before {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.research-hero.immersive-hero {
|
||||
padding: 80px 12px 125px;
|
||||
}
|
||||
|
||||
.hero-kicker {
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 1.8rem;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.hero-tagline {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.hero-floating-stat-bar {
|
||||
flex-direction: row;
|
||||
bottom: 20px;
|
||||
gap: 16px;
|
||||
padding: 14px 20px;
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.stat-bar-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.stat-bar-value {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.research-hero::before {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/* ===========================================
|
||||
Navigation & Mega Menu Styles
|
||||
Extracted from inline <style> in index.html
|
||||
=========================================== */
|
||||
|
||||
/* ===== MOBILE NAV STABILITY FIX =====
|
||||
Prevents logo bar from collapsing/shifting on Android when
|
||||
address bar hides/shows and triggers viewport reflow.
|
||||
===================================== */
|
||||
#nav-wrapper {
|
||||
position: fixed !important;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
will-change: box-shadow;
|
||||
}
|
||||
|
||||
#nav-wrapper>div:first-child {
|
||||
transform: translateZ(0);
|
||||
-webkit-transform: translateZ(0);
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
#nav-wrapper>div:first-child {
|
||||
min-height: 0 !important;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
#pengumuman-bar {
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
#pengumuman-bar.is-stuck {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Skip Link Styling */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: #003150;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
z-index: 100;
|
||||
transition: top 0.3s;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* Focus visible for better keyboard navigation */
|
||||
:focus-visible {
|
||||
outline: 3px solid #feb401;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Mega Menu Styles */
|
||||
.hero-pagination .swiper-pagination-bullet {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
display: inline-block;
|
||||
margin: 0 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.hero-pagination .swiper-pagination-bullet-active {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
width: 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mega-panel {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s ease, visibility 0.2s ease;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.mega-panel.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Overlay transition */
|
||||
#mega-overlay {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s ease, visibility 0.2s ease;
|
||||
}
|
||||
|
||||
#mega-overlay.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.mega-link-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mega-link-card:hover {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.mega-link-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-menu-btn.active {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.nav-menu-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
/* Mega menu positioning - no gap */
|
||||
#main-nav {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#mega-menu-container {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#mega-menu-container .mega-panel {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Keep pointer bridge under nav to prevent hover loss */
|
||||
.nav-menu-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-menu-item::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -12px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* ===== Luco Chatbot Animations ===== */
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
transform: translateY(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lucoPulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
#luco-container {
|
||||
animation: slideInRight 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
#luco-bubble {
|
||||
animation: fadeInUp 0.3s ease-out forwards;
|
||||
width: min(18rem, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
#luco-btn {
|
||||
animation: lucoPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#luco-btn:hover {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Typing indicator */
|
||||
.typing-indicator span {
|
||||
display: inline-block;
|
||||
animation: bounce 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Luco speech bubble arrow */
|
||||
#luco-bubble .bubble-arrow {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
bottom: -6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: white;
|
||||
transform: rotate(45deg);
|
||||
border-right: 1px solid #e2e8f0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/* --- Document Cards Grid --- */
|
||||
|
||||
.document-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.document-card {
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.3s cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.document-card:hover {
|
||||
border-color: var(--secondary-gold);
|
||||
box-shadow: 0 20px 40px rgba(0, 49, 80, 0.05);
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.doc-type-icon {
|
||||
font-size: 2.2rem;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.doc-type-pdf {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.doc-type-docx {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.doc-type-pptx {
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.doc-type-xlsx {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.doc-type-txt {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.doc-type-archive {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.doc-type-image {
|
||||
color: #ec4899;
|
||||
}
|
||||
|
||||
.doc-type-file {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.doc-title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
color: var(--primary-blue);
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.doc-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 25px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.doc-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 800;
|
||||
padding: 4px 10px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.doc-badge.date {
|
||||
background: hsl(200, 95%, 95%);
|
||||
color: hsl(200, 95%, 30%);
|
||||
}
|
||||
|
||||
/* --- Tag Badges --- */
|
||||
|
||||
.doc-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.doc-tags-icon {
|
||||
font-size: 0.7rem;
|
||||
color: #94a3b8;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.doc-tag {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
padding: 3px 10px;
|
||||
background: hsl(43, 100%, 95%);
|
||||
color: hsl(43, 80%, 35%);
|
||||
border: 1px solid hsl(43, 80%, 85%);
|
||||
border-radius: 20px;
|
||||
letter-spacing: 0.3px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.doc-tag:hover {
|
||||
background: hsl(43, 80%, 88%);
|
||||
border-color: hsl(43, 80%, 70%);
|
||||
}
|
||||
|
||||
.doc-actions {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-action-primary,
|
||||
.btn-action-outline {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
font-weight: 800;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
a.btn-action-primary {
|
||||
background: var(--primary-blue);
|
||||
color: white;
|
||||
border: 1px solid var(--primary-blue);
|
||||
}
|
||||
|
||||
a.btn-action-primary:hover {
|
||||
background: var(--secondary-gold);
|
||||
border-color: var(--secondary-gold);
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
a.btn-action-outline {
|
||||
background: transparent;
|
||||
color: var(--primary-blue);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
a.btn-action-outline:hover {
|
||||
border-color: var(--primary-blue);
|
||||
background: #f8fafc;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/* --- Content Area & Search --- */
|
||||
|
||||
.repo-main-content {
|
||||
min-height: 800px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.repo-content-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
.header-title-area .section-title {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 0.95rem;
|
||||
color: #64748b;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.repo-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 24px;
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.search-container {
|
||||
flex: 1;
|
||||
min-width: 260px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.search-input-group {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #94a3b8;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
#doc-search {
|
||||
width: 100%;
|
||||
padding: 16px 16px 16px 50px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--primary-blue);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
#doc-search:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* --- Filter Controls --- */
|
||||
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.date-range-group {
|
||||
min-width: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.select-wrapper {
|
||||
position: relative;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
#sort-filter-wrapper {
|
||||
position: relative;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.select-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--primary-blue);
|
||||
font-size: 0.8rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
/* --- Date Range Filter & Popover --- */
|
||||
.date-picker-trigger {
|
||||
width: 100%;
|
||||
padding: 14px 18px;
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-blue);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.date-picker-trigger:hover {
|
||||
border-color: var(--secondary-gold);
|
||||
background: #fffdf5;
|
||||
}
|
||||
|
||||
.date-picker-trigger:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--primary-blue);
|
||||
box-shadow: 0 0 0 3px rgba(0, 49, 80, 0.08);
|
||||
}
|
||||
|
||||
.date-picker-trigger .trigger-left-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.date-picker-trigger .trigger-arrow {
|
||||
font-size: 0.8rem;
|
||||
transition: transform 0.25s ease;
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.date-range-group.active .date-picker-trigger .trigger-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.date-range-group.active .date-picker-trigger {
|
||||
border-color: var(--primary-blue);
|
||||
box-shadow: 0 0 0 3px rgba(0, 49, 80, 0.08);
|
||||
}
|
||||
|
||||
/* Backdrop */
|
||||
.date-picker-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
/* Popover */
|
||||
.date-picker-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
width: 630px;
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
z-index: 45;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.date-range-group.active .date-picker-popover {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Top Bar */
|
||||
.date-picker-top-bar {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.date-display-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.date-display-field input {
|
||||
width: 110px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-blue);
|
||||
text-align: center;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.date-picker-separator {
|
||||
color: #94a3b8;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.date-picker-body {
|
||||
display: flex;
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* Calendar Pane */
|
||||
.calendar-container {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.calendar-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.month-labels {
|
||||
display: flex;
|
||||
gap: 90px;
|
||||
color: var(--primary-blue);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.month-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.year-select-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.year-select-wrapper:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.calendar-year-select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 2px 16px 2px 6px;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-blue);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.year-select-wrapper .select-arrow {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 0.6rem;
|
||||
color: var(--primary-blue);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: #f1f5f9;
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.calendar-grids {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.calendar-grid-pane {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.day-headers {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
text-align: center;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.days-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
row-gap: 4px;
|
||||
column-gap: 2px;
|
||||
}
|
||||
|
||||
.day-cell {
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.day-cell:hover {
|
||||
background: #f1f5f9;
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.day-cell.empty {
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.day-cell.disabled {
|
||||
color: #cbd5e1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.day-cell.range-start {
|
||||
background: var(--primary-blue) !important;
|
||||
color: white !important;
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.day-cell.range-end {
|
||||
background: var(--primary-blue) !important;
|
||||
color: white !important;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.day-cell.range-start.range-end {
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
.day-cell.range-between {
|
||||
background: #eff6ff !important;
|
||||
color: var(--primary-blue) !important;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Presets Pane */
|
||||
.presets-container {
|
||||
width: 190px;
|
||||
border-left: 1px solid var(--border-color);
|
||||
padding: 16px;
|
||||
background: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.presets-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.presets-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.preset-btn:hover {
|
||||
background: #f8fafc;
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.preset-btn.active {
|
||||
background: #eff6ff;
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.preset-btn.active::after {
|
||||
content: '✓';
|
||||
font-weight: 700;
|
||||
color: var(--primary-blue);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Footer Action Buttons */
|
||||
.date-picker-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 8px 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: white;
|
||||
border: 1px solid #cbd5e1;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #94a3b8;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.apply-btn {
|
||||
background: var(--primary-blue);
|
||||
border: 1px solid var(--primary-blue);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.apply-btn:hover {
|
||||
background: #002238;
|
||||
border-color: #002238;
|
||||
}
|
||||
|
||||
.range-divider {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.filter-select,
|
||||
select#sort-filter {
|
||||
width: 100%;
|
||||
padding: 12px 12px 12px 36px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-blue);
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-select:hover,
|
||||
select#sort-filter:hover {
|
||||
border-color: var(--secondary-gold);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.filter-select:focus,
|
||||
select#sort-filter:focus {
|
||||
outline: none;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/* --- Custom UI Dropdown --- */
|
||||
.custom-dropdown {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.custom-dropdown-trigger {
|
||||
width: 100%;
|
||||
padding: 14px 18px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-blue);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.custom-dropdown-trigger:hover {
|
||||
border-color: var(--secondary-gold);
|
||||
background: #fffdf5;
|
||||
}
|
||||
|
||||
.custom-dropdown-trigger:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--primary-blue);
|
||||
box-shadow: 0 0 0 3px rgba(0, 49, 80, 0.08);
|
||||
}
|
||||
|
||||
.custom-dropdown-trigger .trigger-arrow {
|
||||
font-size: 0.8rem;
|
||||
transition: transform 0.25s ease;
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.custom-dropdown.active .custom-dropdown-trigger .trigger-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.custom-dropdown.active .custom-dropdown-trigger {
|
||||
border-color: var(--primary-blue);
|
||||
box-shadow: 0 0 0 3px rgba(0, 49, 80, 0.08);
|
||||
}
|
||||
|
||||
.custom-dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
z-index: 100;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-8px);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.custom-dropdown.active .custom-dropdown-menu {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.custom-dropdown-item {
|
||||
padding: 12px 18px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.custom-dropdown-item:hover {
|
||||
background: #f1f5f9;
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.custom-dropdown-item.active {
|
||||
background: #f0f7ff;
|
||||
color: var(--primary-blue);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Tick mark for selected item */
|
||||
.custom-dropdown-item.active::after {
|
||||
content: "\f00c";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 900;
|
||||
font-size: 0.75rem;
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
/* --- Filter Skeleton Loading --- */
|
||||
|
||||
.filter-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-skeleton-item {
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.filter-skeleton-item.short {
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.sidebar-info-box {
|
||||
background: #f1f5f9;
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar-info-box i {
|
||||
color: var(--primary-blue);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.sidebar-info-box p {
|
||||
font-size: 0.82rem;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@import url("layout.css");
|
||||
@import url("dropdown.css");
|
||||
@import url("controls.css");
|
||||
@import url("date-picker.css");
|
||||
@import url("cards.css");
|
||||
@import url("responsive.css");
|
||||
@import url("pagination.css");
|
||||
@@ -0,0 +1,81 @@
|
||||
/* ===========================================
|
||||
Document Hub & Dashboard Layout
|
||||
=========================================== */
|
||||
|
||||
.documents-section {
|
||||
padding: 120px 5%;
|
||||
background: hsl(0, 0%, 100%);
|
||||
}
|
||||
|
||||
.repo-dashboard-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 40px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* --- Sidebar Styling --- */
|
||||
|
||||
.repo-sidebar {
|
||||
position: sticky;
|
||||
top: 150px;
|
||||
}
|
||||
|
||||
/* Mobile category dropdown wrapper (hidden on desktop) */
|
||||
.mobile-category-control-group {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 2px;
|
||||
font-weight: 800;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 20px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.document-filter-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
/* Desktop: show buttons, hide dropdown */
|
||||
.filter-btn-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-select-group {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.doc-filter-btn {
|
||||
text-align: left;
|
||||
padding: 14px 18px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-blue);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.doc-filter-btn.active {
|
||||
background: var(--primary-blue);
|
||||
color: white;
|
||||
border-color: var(--primary-blue);
|
||||
box-shadow: 0 4px 12px rgba(0, 49, 80, 0.15);
|
||||
}
|
||||
|
||||
.doc-filter-btn:hover:not(.active) {
|
||||
border-color: var(--secondary-gold);
|
||||
background: #fffdf5;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/* ===========================================
|
||||
Pagination
|
||||
=========================================== */
|
||||
|
||||
.doc-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32px 0 0;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.pagination-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
color: var(--primary-blue);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(.disabled):not(.active) {
|
||||
border-color: var(--secondary-gold);
|
||||
background: #fffdf5;
|
||||
}
|
||||
|
||||
.pagination-btn.active {
|
||||
background: var(--primary-blue);
|
||||
border-color: var(--primary-blue);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pagination-btn.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pagination-ellipsis {
|
||||
min-width: 32px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #94a3b8;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.doc-pagination {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.pagination-btn {
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.pagination-nav {
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Responsive — Phone (≤480px)
|
||||
=========================================== */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.documents-section {
|
||||
padding: 50px 12px;
|
||||
}
|
||||
|
||||
.custom-dropdown-trigger {
|
||||
padding: 12px 14px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Single column cards */
|
||||
.document-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.document-card {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.doc-title {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.doc-type-icon {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.doc-meta {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.doc-tags {
|
||||
margin-bottom: 10px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.doc-tag {
|
||||
font-size: 0.6rem;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.btn-action-primary,
|
||||
.btn-action-outline {
|
||||
padding: 10px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.doc-actions {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.repo-controls {
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#doc-search {
|
||||
padding: 12px 12px 12px 40px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
left: 14px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.repo-content-header {
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/* ===========================================
|
||||
Responsive — Tablet (≤1024px)
|
||||
=========================================== */
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.repo-dashboard-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.repo-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-category-control-group {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Mobile: show dropdown */
|
||||
.filter-select-group {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Skeleton single bar on mobile */
|
||||
.filter-skeleton {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-skeleton-item {
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.filter-skeleton-item.short {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filter-skeleton-item:not(.short) ~ .filter-skeleton-item:not(.short) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.repo-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Responsive — Tablet / Large Phone (≤768px)
|
||||
=========================================== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.documents-section {
|
||||
padding: 80px 16px;
|
||||
}
|
||||
|
||||
.repo-content-header {
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.repo-controls {
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#doc-search {
|
||||
padding: 14px 14px 14px 44px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.date-range-group {
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body.date-picker-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.date-picker-open .date-picker-backdrop {
|
||||
display: block;
|
||||
z-index: 998;
|
||||
}
|
||||
|
||||
.date-picker-popover {
|
||||
/* Default mobile popover positioning inside media query is overridden below,
|
||||
but let's define the base transitions or layouts. */
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.date-picker-popover {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
transform: translate(-50%, -50%);
|
||||
width: calc(100% - 32px);
|
||||
max-width: 380px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15), 0 10px 10px -5px rgba(0, 0, 0, 0.1);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.date-picker-top-bar {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.calendar-grids {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
#right-calendar-pane {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.month-labels {
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#right-month-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.date-picker-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.presets-container {
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.presets-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
padding: 6px 10px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.select-wrapper,
|
||||
#sort-filter-wrapper {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Cards: 2-column when space permits, else single */
|
||||
.document-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.document-card {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.document-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.doc-type-icon {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.doc-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.doc-meta {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.doc-tags {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.repo-main-content {
|
||||
min-height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Responsive — Phone (≤480px)
|
||||
=========================================== */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.date-dd, .date-mm {
|
||||
width: 26px;
|
||||
}
|
||||
|
||||
.date-yyyy {
|
||||
min-width: 38px;
|
||||
}
|
||||
|
||||
.date-calendar-btn {
|
||||
padding-left: 4px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/* ===========================================
|
||||
Responsive Breakpoints — Document Sections
|
||||
=========================================== */
|
||||
|
||||
/* --- Tablet (≤768px) --- */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tapops-doc-root .section-title {
|
||||
font-size: 1.8rem;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.tapops-doc-root .section-tag {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 2px;
|
||||
padding: 5px 12px;
|
||||
}
|
||||
|
||||
.tapops-doc-root .section-header {
|
||||
margin-bottom: 35px;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Phone (≤480px) --- */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.tapops-doc-root .section-title {
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.tapops-doc-root .section-tag {
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 1.5px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.tapops-doc-root .section-subtitle {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.tapops-doc-root .section-header {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
/* ===========================================
|
||||
Procedures (Modern Process Flow)
|
||||
=========================================== */
|
||||
|
||||
.journey-section {
|
||||
padding: 120px 5%;
|
||||
background: #ffffff;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.journey-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.process-flow-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 40px;
|
||||
margin-top: 80px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.process-step {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.step-visual {
|
||||
position: relative;
|
||||
margin-bottom: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.step-icon-box {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.8rem;
|
||||
color: var(--primary-blue);
|
||||
box-shadow: 0 10px 25px rgba(0, 49, 80, 0.05);
|
||||
transition: all 0.3s cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.process-step:hover .step-icon-box {
|
||||
border-color: var(--secondary-gold);
|
||||
color: var(--secondary-gold);
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 15px 35px rgba(254, 180, 1, 0.15);
|
||||
}
|
||||
|
||||
.step-number-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: calc(50% - 38px);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--secondary-gold);
|
||||
color: var(--primary-blue);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
z-index: 3;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.step-connector {
|
||||
position: absolute;
|
||||
left: calc(50% + 50px);
|
||||
top: 50%;
|
||||
width: calc(100% - 100px);
|
||||
height: 2px;
|
||||
background: repeating-linear-gradient(to right, #e2e8f0, #e2e8f0 5px, transparent 5px, transparent 10px);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.step-content h3 {
|
||||
font-size: 1.25rem;
|
||||
color: var(--primary-blue);
|
||||
font-weight: 800;
|
||||
margin-bottom: 15px;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.step-content p {
|
||||
font-size: 0.95rem;
|
||||
color: #64748b;
|
||||
line-height: 1.7;
|
||||
max-width: 280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.process-flow-wrapper {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.step-connector {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.process-flow-wrapper {
|
||||
flex-direction: column;
|
||||
gap: 60px;
|
||||
}
|
||||
|
||||
.process-step {
|
||||
flex-direction: row;
|
||||
text-align: left;
|
||||
align-items: flex-start;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.step-visual {
|
||||
width: 80px;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.step-number-badge {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.step-content p {
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ===========================================
|
||||
Help Center (Interaction Cards)
|
||||
=========================================== */
|
||||
|
||||
.help-center-section {
|
||||
padding: 100px 5%;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.help-container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.help-header-centered {
|
||||
text-align: center;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.help-cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.interaction-card {
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.help-official {
|
||||
background: linear-gradient(135deg, var(--primary-blue) 0%, #001e35 100%);
|
||||
}
|
||||
|
||||
.help-urgent {
|
||||
background: linear-gradient(135deg, var(--secondary-gold) 0%, #ffd54f 100%);
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
background: white;
|
||||
padding: 50px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.help-official .card-inner {
|
||||
border: 1px solid rgba(0, 49, 80, 0.05);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: #f8fafc;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 30px;
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.help-urgent .card-icon {
|
||||
background: rgba(254, 180, 1, 0.1);
|
||||
color: var(--secondary-gold);
|
||||
}
|
||||
|
||||
.interaction-card h3 {
|
||||
font-size: 1.5rem;
|
||||
color: var(--primary-blue);
|
||||
margin-bottom: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.interaction-card p {
|
||||
font-size: 1rem;
|
||||
color: #64748b;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.card-checklist {
|
||||
list-style: none;
|
||||
margin-bottom: 40px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card-checklist li {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-blue);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-checklist li i {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
a.btn-interaction,
|
||||
a.btn-interaction-alt {
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
a.btn-interaction {
|
||||
background: var(--primary-blue);
|
||||
color: white;
|
||||
}
|
||||
|
||||
a.btn-interaction-alt {
|
||||
background: var(--secondary-gold);
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
a.btn-interaction:hover {
|
||||
background: #0f172a;
|
||||
transform: translateY(-2px);
|
||||
color: white;
|
||||
}
|
||||
|
||||
a.btn-interaction-alt:hover {
|
||||
background: #facc15;
|
||||
transform: translateY(-2px);
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.journey-section {
|
||||
padding: 80px 16px;
|
||||
}
|
||||
|
||||
.help-center-section {
|
||||
padding: 80px 16px;
|
||||
}
|
||||
|
||||
.help-cards-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
padding: 35px 24px;
|
||||
}
|
||||
|
||||
.interaction-card h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.interaction-card p {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
font-size: 1.6rem;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
a.btn-interaction,
|
||||
a.btn-interaction-alt {
|
||||
padding: 14px;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
|
||||
.process-flow-wrapper {
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
.step-content h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.step-content p {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.journey-section {
|
||||
padding: 50px 12px;
|
||||
}
|
||||
|
||||
.help-center-section {
|
||||
padding: 50px 12px;
|
||||
}
|
||||
|
||||
.help-header-centered {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
padding: 28px 18px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.interaction-card h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.interaction-card p {
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-checklist {
|
||||
margin-bottom: 28px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-checklist li {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
a.btn-interaction,
|
||||
a.btn-interaction-alt {
|
||||
padding: 12px;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.process-flow-wrapper {
|
||||
margin-top: 35px;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.process-step {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.step-icon-box {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.step-content h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.step-content p {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.step-number-badge {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/* ===========================================
|
||||
Design Tokens — TAPOPS (Informatika UNTAN)
|
||||
Overrides edo variable names with Tapops palette.
|
||||
All document section CSS files inherit from here.
|
||||
=========================================== */
|
||||
|
||||
:root {
|
||||
--primary-blue: #003150; /* Tapops navy (was #1b3652) */
|
||||
--secondary-gold: #feb401; /* Tapops yellow (was #f7b738) */
|
||||
--text-dark: #1e293b; /* slate-800 (was #222) */
|
||||
--text-light: #fff;
|
||||
--bg-light: #f8fafc; /* slate-50 (was #f4f6f9) */
|
||||
--border-color: #e2e8f0; /* slate-200 (was #eaeaea) */
|
||||
--shadow: 0 18px 35px rgba(0, 49, 80, 0.08);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/* ===========================================
|
||||
Viewer Page — PDF iframe + Office Online preview
|
||||
=========================================== */
|
||||
|
||||
.viewer-body {
|
||||
margin: 0;
|
||||
background: var(--bg-light);
|
||||
font-family: 'Roboto', sans-serif;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* --- Header --- */
|
||||
|
||||
.viewer-header {
|
||||
background: var(--primary-blue);
|
||||
color: var(--text-light);
|
||||
padding: 16px 32px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
box-shadow: var(--shadow);
|
||||
flex-wrap: wrap;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.viewer-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.viewer-title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.viewer-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-light);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.viewer-type-badge {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
padding: 2px 10px;
|
||||
background: var(--secondary-gold);
|
||||
color: var(--primary-blue);
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* --- Buttons --- */
|
||||
|
||||
.btn-back,
|
||||
.btn-download,
|
||||
.btn-back-mini {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-light);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-download {
|
||||
background: var(--secondary-gold);
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.btn-download:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 16px rgba(254, 180, 1, 0.4);
|
||||
}
|
||||
|
||||
.btn-back-mini {
|
||||
background: var(--primary-blue);
|
||||
color: var(--text-light);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.btn-back-mini:hover {
|
||||
background: #001e35;
|
||||
}
|
||||
|
||||
/* --- Content area --- */
|
||||
|
||||
.viewer-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #525659;
|
||||
min-height: calc(100vh - 80px);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.viewer-pdf-frame,
|
||||
.viewer-office-frame {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
border: none;
|
||||
min-height: calc(100vh - 80px);
|
||||
}
|
||||
|
||||
/* --- States --- */
|
||||
|
||||
.viewer-loading,
|
||||
.viewer-error {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
color: var(--text-light);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.viewer-loading i {
|
||||
font-size: 3rem;
|
||||
color: var(--secondary-gold);
|
||||
}
|
||||
|
||||
.viewer-error i {
|
||||
font-size: 3rem;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* --- Responsive --- */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.viewer-header {
|
||||
padding: 12px 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.viewer-header-left {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.viewer-title {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn-back,
|
||||
.btn-download {
|
||||
padding: 8px 14px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
+17
-1325
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
import { CounterAnimation } from './components/counter-animation.js';
|
||||
import { AccessibilityToggleController } from './controllers/accessibility-toggle-controller.js';
|
||||
import { DocumentRepository } from './controllers/document-repository.js';
|
||||
import { MobileMenuController } from './controllers/mobile-menu-controller.js';
|
||||
import { NavigationController } from './controllers/navigation-controller.js';
|
||||
import { pageTemplate } from './templates/page-template.js';
|
||||
|
||||
class TapopsApp {
|
||||
constructor(root = document.getElementById('tapops-page-root')) {
|
||||
this.root = root;
|
||||
}
|
||||
|
||||
init() {
|
||||
if (!this.root) return;
|
||||
|
||||
this.root.innerHTML = pageTemplate();
|
||||
window.applyLang?.();
|
||||
window.applyTp?.(this.root);
|
||||
|
||||
new NavigationController().init();
|
||||
new MobileMenuController().init();
|
||||
new AccessibilityToggleController().init();
|
||||
new CounterAnimation().init();
|
||||
new DocumentRepository().init();
|
||||
}
|
||||
}
|
||||
|
||||
new TapopsApp().init();
|
||||
@@ -0,0 +1,44 @@
|
||||
export class CounterAnimation {
|
||||
static DURATION_MS = 2000;
|
||||
|
||||
init() {
|
||||
const counters = document.querySelectorAll('.counter-value[data-target]');
|
||||
if (counters.length === 0) return;
|
||||
|
||||
if ('IntersectionObserver' in window) {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
this._animate(entry.target);
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.2 });
|
||||
counters.forEach(c => observer.observe(c));
|
||||
} else {
|
||||
counters.forEach(c => this._animate(c));
|
||||
}
|
||||
}
|
||||
|
||||
_animate(el) {
|
||||
const target = Number.parseInt((el.dataset.target || '0').replace(/[^0-9]/g, ''), 10);
|
||||
if (Number.isNaN(target)) return;
|
||||
CounterAnimation.animateValue(el, target, el.dataset.suffix || '');
|
||||
}
|
||||
|
||||
// Reusable easing tween from 0 → target. Callable for stats whose
|
||||
// value is only known after an async fetch (no data-target needed).
|
||||
static animateValue(el, target, suffix = '') {
|
||||
if (!el || Number.isNaN(target)) return;
|
||||
const startTime = performance.now();
|
||||
|
||||
const tick = (now) => {
|
||||
const progress = Math.min((now - startTime) / CounterAnimation.DURATION_MS, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
el.textContent = Math.floor(eased * target) + suffix;
|
||||
if (progress < 1) requestAnimationFrame(tick);
|
||||
else el.textContent = target + suffix;
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
export class CustomDropdown {
|
||||
constructor({ container, options, defaultValue, onChange, prefixIcon }) {
|
||||
this.container = container;
|
||||
this.options = options; // Array of { value, text, i18nKey }
|
||||
this.onChange = onChange;
|
||||
this.currentValue = defaultValue || options[0]?.value;
|
||||
this.prefixIcon = prefixIcon; // optional class name like 'fas fa-sort'
|
||||
this.isOpen = false;
|
||||
|
||||
this.elements = {};
|
||||
this._build();
|
||||
this._bindEvents();
|
||||
}
|
||||
|
||||
_build() {
|
||||
this.container.innerHTML = '';
|
||||
this.container.classList.add('custom-dropdown');
|
||||
|
||||
// Trigger Button
|
||||
const trigger = document.createElement('button');
|
||||
trigger.className = 'custom-dropdown-trigger';
|
||||
trigger.setAttribute('aria-haspopup', 'listbox');
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
trigger.type = 'button';
|
||||
|
||||
const triggerLeft = document.createElement('div');
|
||||
triggerLeft.className = 'trigger-left-content';
|
||||
triggerLeft.style.display = 'flex';
|
||||
triggerLeft.style.alignItems = 'center';
|
||||
triggerLeft.style.gap = '10px';
|
||||
|
||||
if (this.prefixIcon) {
|
||||
const icon = document.createElement('i');
|
||||
icon.className = this.prefixIcon;
|
||||
triggerLeft.appendChild(icon);
|
||||
}
|
||||
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.className = 'trigger-label';
|
||||
|
||||
// Find default text
|
||||
const defaultOpt = this.options.find(opt => opt.value === this.currentValue) || this.options[0];
|
||||
labelSpan.textContent = defaultOpt ? defaultOpt.text : '';
|
||||
if (defaultOpt?.i18nKey) {
|
||||
labelSpan.setAttribute('data-tp-i18n', defaultOpt.i18nKey);
|
||||
}
|
||||
|
||||
triggerLeft.appendChild(labelSpan);
|
||||
trigger.appendChild(triggerLeft);
|
||||
|
||||
const arrowIcon = document.createElement('i');
|
||||
arrowIcon.className = 'fas fa-chevron-down trigger-arrow';
|
||||
|
||||
trigger.appendChild(arrowIcon);
|
||||
this.container.appendChild(trigger);
|
||||
|
||||
// Menu wrapper
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'custom-dropdown-menu';
|
||||
menu.setAttribute('role', 'listbox');
|
||||
|
||||
// Populate options
|
||||
this.options.forEach(opt => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'custom-dropdown-item' + (opt.value === this.currentValue ? ' active' : '');
|
||||
item.setAttribute('role', 'option');
|
||||
item.setAttribute('data-value', opt.value);
|
||||
|
||||
const itemText = document.createElement('span');
|
||||
itemText.textContent = opt.text;
|
||||
if (opt.i18nKey) {
|
||||
itemText.setAttribute('data-tp-i18n', opt.i18nKey);
|
||||
}
|
||||
|
||||
item.appendChild(itemText);
|
||||
menu.appendChild(item);
|
||||
});
|
||||
|
||||
this.container.appendChild(menu);
|
||||
|
||||
this.elements = { trigger, labelSpan, menu };
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
// Toggle dropdown on trigger click
|
||||
this.elements.trigger.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggle();
|
||||
});
|
||||
|
||||
// Handle item selection
|
||||
this.elements.menu.addEventListener('click', (e) => {
|
||||
const item = e.target.closest('.custom-dropdown-item');
|
||||
if (!item) return;
|
||||
|
||||
const value = item.getAttribute('data-value');
|
||||
this.select(value);
|
||||
this.close();
|
||||
});
|
||||
|
||||
// Close when clicking outside
|
||||
document.addEventListener('click', () => {
|
||||
this.close();
|
||||
});
|
||||
|
||||
// Close on escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') this.close();
|
||||
});
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
// Close other custom dropdowns
|
||||
document.querySelectorAll('.custom-dropdown.active').forEach(dropdown => {
|
||||
if (dropdown !== this.container) {
|
||||
dropdown.classList.remove('active');
|
||||
dropdown.querySelector('.custom-dropdown-trigger')?.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
|
||||
this.isOpen = true;
|
||||
this.container.classList.add('active');
|
||||
this.elements.trigger.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.container.classList.remove('active');
|
||||
this.elements.trigger.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
select(value) {
|
||||
if (value === this.currentValue) return;
|
||||
|
||||
this.currentValue = value;
|
||||
|
||||
// Update active class in menu items
|
||||
this.elements.menu.querySelectorAll('.custom-dropdown-item').forEach(item => {
|
||||
const isCurrent = item.getAttribute('data-value') === value;
|
||||
item.classList.toggle('active', isCurrent);
|
||||
});
|
||||
|
||||
// Update trigger label
|
||||
const selectedOpt = this.options.find(opt => opt.value === value);
|
||||
if (selectedOpt) {
|
||||
this.elements.labelSpan.textContent = selectedOpt.text;
|
||||
if (selectedOpt.i18nKey) {
|
||||
this.elements.labelSpan.setAttribute('data-tp-i18n', selectedOpt.i18nKey);
|
||||
} else {
|
||||
this.elements.labelSpan.removeAttribute('data-tp-i18n');
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger callback
|
||||
if (typeof this.onChange === 'function') {
|
||||
this.onChange(value);
|
||||
}
|
||||
}
|
||||
|
||||
setValue(value) {
|
||||
this.select(value);
|
||||
}
|
||||
|
||||
getValue() {
|
||||
return this.currentValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
export class DateRangePicker {
|
||||
constructor({ containerId, onChange, initialStartDate = '', initialEndDate = '' }) {
|
||||
this.container = document.getElementById(containerId);
|
||||
if (!this.container) return;
|
||||
|
||||
this.trigger = this.container.querySelector('#date-picker-trigger');
|
||||
this.popover = this.container.querySelector('#date-picker-popover');
|
||||
this.backdrop = this.container.querySelector('#date-picker-backdrop');
|
||||
this.label = this.container.querySelector('#date-picker-label');
|
||||
|
||||
this.startDisplay = this.container.querySelector('#picker-start-display');
|
||||
this.endDisplay = this.container.querySelector('#picker-end-display');
|
||||
|
||||
this.prevBtn = this.container.querySelector('#prev-month-btn');
|
||||
this.nextBtn = this.container.querySelector('#next-month-btn');
|
||||
this.leftMonthLabel = this.container.querySelector('#left-month-label');
|
||||
this.rightMonthLabel = this.container.querySelector('#right-month-label');
|
||||
|
||||
this.leftMonthName = this.container.querySelector('#left-month-name');
|
||||
this.rightMonthName = this.container.querySelector('#right-month-name');
|
||||
this.leftYearSelect = this.container.querySelector('#left-year-select');
|
||||
this.rightYearSelect = this.container.querySelector('#right-year-select');
|
||||
|
||||
this.leftPane = this.container.querySelector('#left-calendar-pane');
|
||||
this.rightPane = this.container.querySelector('#right-calendar-pane');
|
||||
|
||||
this.leftDaysContainer = this.container.querySelector('#left-days-container');
|
||||
this.rightDaysContainer = this.container.querySelector('#right-days-container');
|
||||
|
||||
this.cancelBtn = this.container.querySelector('#picker-cancel-btn');
|
||||
this.applyBtn = this.container.querySelector('#picker-apply-btn');
|
||||
|
||||
this.onChange = onChange;
|
||||
|
||||
// Active dates (saved)
|
||||
this.startDate = initialStartDate ? new Date(initialStartDate) : null;
|
||||
this.endDate = initialEndDate ? new Date(initialEndDate) : null;
|
||||
|
||||
// Temporary dates (being edited)
|
||||
this.tempStartDate = this.startDate ? new Date(this.startDate) : null;
|
||||
this.tempEndDate = this.endDate ? new Date(this.endDate) : null;
|
||||
|
||||
// Calendar view month/year
|
||||
const today = new Date();
|
||||
if (this.endDate) {
|
||||
this.currentLeftMonth = this.endDate.getMonth();
|
||||
this.currentLeftYear = this.endDate.getFullYear();
|
||||
} else {
|
||||
this.currentLeftMonth = today.getMonth();
|
||||
this.currentLeftYear = today.getFullYear();
|
||||
}
|
||||
|
||||
this.isOpen = false;
|
||||
this._init();
|
||||
}
|
||||
|
||||
_init() {
|
||||
this._populateYearSelects();
|
||||
this._bindEvents();
|
||||
this._bindDisplayInputs();
|
||||
this._updateTriggerLabel();
|
||||
|
||||
// Listen to language changes
|
||||
document.addEventListener('languageChanged', () => {
|
||||
if (this.isOpen) {
|
||||
this._renderCalendars();
|
||||
}
|
||||
this._updateTriggerLabel();
|
||||
});
|
||||
}
|
||||
|
||||
_isEnglish() {
|
||||
if (typeof window.getLang === 'function') {
|
||||
const l = window.getLang();
|
||||
if (l === 'id' || l === 'en') return l === 'en';
|
||||
}
|
||||
try {
|
||||
const saved = localStorage.getItem('if_untan_lang');
|
||||
if (saved === 'id' || saved === 'en') return saved === 'en';
|
||||
} catch (e) {}
|
||||
const nav = (navigator.language || 'id').toLowerCase();
|
||||
return nav.startsWith('en');
|
||||
}
|
||||
|
||||
_clearTime(date) {
|
||||
if (!date) return null;
|
||||
const d = new Date(date);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
_datesEqual(d1, d2) {
|
||||
if (!d1 || !d2) return false;
|
||||
return d1.getFullYear() === d2.getFullYear() &&
|
||||
d1.getMonth() === d2.getMonth() &&
|
||||
d1.getDate() === d2.getDate();
|
||||
}
|
||||
|
||||
_getRightMonthYear() {
|
||||
let m = this.currentLeftMonth + 1;
|
||||
let y = this.currentLeftYear;
|
||||
if (m > 11) {
|
||||
m = 0;
|
||||
y += 1;
|
||||
}
|
||||
return { month: m, year: y };
|
||||
}
|
||||
|
||||
_navigateMonths(direction) {
|
||||
this.currentLeftMonth += direction;
|
||||
if (this.currentLeftMonth > 11) {
|
||||
this.currentLeftMonth = 0;
|
||||
this.currentLeftYear += 1;
|
||||
} else if (this.currentLeftMonth < 0) {
|
||||
this.currentLeftMonth = 11;
|
||||
this.currentLeftYear -= 1;
|
||||
}
|
||||
this._renderCalendars();
|
||||
}
|
||||
|
||||
_renderCalendars() {
|
||||
const isEn = this._isEnglish();
|
||||
const monthNames = isEn ?
|
||||
['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] :
|
||||
['Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'];
|
||||
|
||||
const dayHeaders = isEn ?
|
||||
['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] :
|
||||
['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min'];
|
||||
|
||||
if (this.leftMonthName) {
|
||||
this.leftMonthName.textContent = monthNames[this.currentLeftMonth];
|
||||
}
|
||||
if (this.leftYearSelect) {
|
||||
this.leftYearSelect.value = this.currentLeftYear;
|
||||
}
|
||||
|
||||
const rightMY = this._getRightMonthYear();
|
||||
if (this.rightMonthName) {
|
||||
this.rightMonthName.textContent = monthNames[rightMY.month];
|
||||
}
|
||||
if (this.rightYearSelect) {
|
||||
this.rightYearSelect.value = rightMY.year;
|
||||
}
|
||||
|
||||
const drawHeaders = (pane) => {
|
||||
if (!pane) return;
|
||||
const dayHeadersEl = pane.querySelector('.day-headers');
|
||||
if (!dayHeadersEl) return;
|
||||
dayHeadersEl.innerHTML = '';
|
||||
dayHeaders.forEach(day => {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = day;
|
||||
dayHeadersEl.appendChild(span);
|
||||
});
|
||||
};
|
||||
|
||||
drawHeaders(this.leftPane);
|
||||
drawHeaders(this.rightPane);
|
||||
|
||||
if (this.leftDaysContainer) {
|
||||
this._populateDaysContainer(this.leftDaysContainer, this.currentLeftMonth, this.currentLeftYear);
|
||||
}
|
||||
if (this.rightDaysContainer) {
|
||||
this._populateDaysContainer(this.rightDaysContainer, rightMY.month, rightMY.year);
|
||||
}
|
||||
|
||||
this._updateDisplayInputs();
|
||||
this._updatePresetSelectionVisuals();
|
||||
}
|
||||
|
||||
_populateDaysContainer(container, month, year) {
|
||||
container.innerHTML = '';
|
||||
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const numDays = new Date(year, month + 1, 0).getDate();
|
||||
|
||||
let offset = (firstDay.getDay() + 6) % 7;
|
||||
|
||||
for (let i = 0; i < offset; i++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'day-cell empty';
|
||||
container.appendChild(cell);
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(23, 59, 59, 999);
|
||||
|
||||
for (let day = 1; day <= numDays; day++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'day-cell';
|
||||
cell.textContent = day;
|
||||
|
||||
const cellDate = new Date(year, month, day);
|
||||
cellDate.setHours(0, 0, 0, 0);
|
||||
|
||||
if (cellDate > today) {
|
||||
cell.classList.add('disabled');
|
||||
} else {
|
||||
if (this.tempStartDate && this.tempEndDate) {
|
||||
const start = this._clearTime(this.tempStartDate);
|
||||
const end = this._clearTime(this.tempEndDate);
|
||||
|
||||
if (this._datesEqual(cellDate, start)) {
|
||||
cell.classList.add('range-start');
|
||||
}
|
||||
if (this._datesEqual(cellDate, end)) {
|
||||
cell.classList.add('range-end');
|
||||
}
|
||||
if (cellDate > start && cellDate < end) {
|
||||
cell.classList.add('range-between');
|
||||
}
|
||||
} else if (this.tempStartDate && this._datesEqual(cellDate, this._clearTime(this.tempStartDate))) {
|
||||
cell.classList.add('range-start');
|
||||
cell.classList.add('range-end');
|
||||
}
|
||||
|
||||
cell.addEventListener('click', () => {
|
||||
this._handleDayClick(cellDate);
|
||||
});
|
||||
}
|
||||
|
||||
container.appendChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
_handleDayClick(date) {
|
||||
if (!this.tempStartDate || (this.tempStartDate && this.tempEndDate)) {
|
||||
this.tempStartDate = new Date(date);
|
||||
this.tempEndDate = null;
|
||||
} else {
|
||||
if (date < this.tempStartDate) {
|
||||
this.tempStartDate = new Date(date);
|
||||
this.tempEndDate = null;
|
||||
} else {
|
||||
this.tempEndDate = new Date(date);
|
||||
}
|
||||
}
|
||||
this._renderCalendars();
|
||||
}
|
||||
|
||||
_formatDateDisplay(date) {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const yyyy = d.getFullYear();
|
||||
return `${dd}/${mm}/${yyyy}`;
|
||||
}
|
||||
|
||||
_updateDisplayInputs() {
|
||||
if (this.startDisplay) {
|
||||
this.startDisplay.value = this._formatDateDisplay(this.tempStartDate);
|
||||
}
|
||||
if (this.endDisplay) {
|
||||
this.endDisplay.value = this._formatDateDisplay(this.tempEndDate);
|
||||
}
|
||||
}
|
||||
|
||||
_calculatePreset(presetName) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
let start = null;
|
||||
let end = null;
|
||||
|
||||
switch (presetName) {
|
||||
case 'today':
|
||||
start = new Date(today);
|
||||
end = new Date(today);
|
||||
break;
|
||||
case 'yesterday':
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - 1);
|
||||
end = new Date(start);
|
||||
break;
|
||||
case 'last7':
|
||||
end = new Date(today);
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - 6);
|
||||
break;
|
||||
case 'last14':
|
||||
end = new Date(today);
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - 13);
|
||||
break;
|
||||
case 'last30':
|
||||
end = new Date(today);
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - 29);
|
||||
break;
|
||||
case 'thisWeek':
|
||||
end = new Date(today);
|
||||
start = new Date(today);
|
||||
const day = today.getDay();
|
||||
const diff = today.getDate() - day + (day === 0 ? -6 : 1);
|
||||
start.setDate(diff);
|
||||
break;
|
||||
case 'lastWeek':
|
||||
const temp = new Date(today);
|
||||
const tempDay = temp.getDay();
|
||||
const tempDiff = temp.getDate() - tempDay + (tempDay === 0 ? -6 : 1);
|
||||
start = new Date(temp);
|
||||
start.setDate(tempDiff - 7);
|
||||
end = new Date(temp);
|
||||
end.setDate(tempDiff - 1);
|
||||
break;
|
||||
case 'thisMonth':
|
||||
start = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
end = new Date(today);
|
||||
break;
|
||||
case 'lastMonth':
|
||||
start = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
||||
end = new Date(today.getFullYear(), today.getMonth(), 0);
|
||||
break;
|
||||
case 'all':
|
||||
default:
|
||||
start = null;
|
||||
end = null;
|
||||
break;
|
||||
}
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
_handlePresetClick(presetName) {
|
||||
const { start, end } = this._calculatePreset(presetName);
|
||||
this.tempStartDate = start;
|
||||
this.tempEndDate = end;
|
||||
|
||||
const today = new Date();
|
||||
if (this.tempEndDate) {
|
||||
this.currentLeftMonth = this.tempEndDate.getMonth();
|
||||
this.currentLeftYear = this.tempEndDate.getFullYear();
|
||||
} else {
|
||||
this.currentLeftMonth = today.getMonth();
|
||||
this.currentLeftYear = today.getFullYear();
|
||||
}
|
||||
this._renderCalendars();
|
||||
}
|
||||
|
||||
_updatePresetSelectionVisuals() {
|
||||
this.container.querySelectorAll('.preset-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
|
||||
if (!this.tempStartDate && !this.tempEndDate) {
|
||||
this.container.querySelector('[data-preset="all"]')?.classList.add('active');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.tempStartDate && this.tempEndDate) {
|
||||
const activePreset = ['today', 'yesterday', 'last7', 'last14', 'last30', 'thisWeek', 'lastWeek', 'thisMonth', 'lastMonth'].find(preset => {
|
||||
const { start, end } = this._calculatePreset(preset);
|
||||
return start && end &&
|
||||
this._datesEqual(this.tempStartDate, start) &&
|
||||
this._datesEqual(this.tempEndDate, end);
|
||||
});
|
||||
|
||||
if (activePreset) {
|
||||
this.container.querySelector(`[data-preset="${activePreset}"]`)?.classList.add('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_updateTriggerLabel() {
|
||||
if (!this.startDate && !this.endDate) {
|
||||
if (this.label) {
|
||||
this.label.textContent = window.tpT?.('filter_all_time') || 'Semua Waktu';
|
||||
this.label.setAttribute('data-tp-i18n', 'filter_all_time');
|
||||
}
|
||||
} else {
|
||||
if (this.label) {
|
||||
this.label.removeAttribute('data-tp-i18n');
|
||||
const startFormatted = this._formatDateDisplay(this.startDate);
|
||||
const endFormatted = this._formatDateDisplay(this.endDate);
|
||||
if (this._datesEqual(this.startDate, this.endDate)) {
|
||||
this.label.textContent = startFormatted;
|
||||
} else {
|
||||
this.label.textContent = `${startFormatted} - ${endFormatted}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
this.trigger.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggle();
|
||||
});
|
||||
|
||||
this.popover.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
if (this.prevBtn) {
|
||||
this.prevBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._navigateMonths(-1);
|
||||
});
|
||||
}
|
||||
if (this.nextBtn) {
|
||||
this.nextBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._navigateMonths(1);
|
||||
});
|
||||
}
|
||||
|
||||
const presetsList = this.container.querySelector('.presets-list');
|
||||
if (presetsList) {
|
||||
presetsList.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.preset-btn');
|
||||
if (!btn) return;
|
||||
e.stopPropagation();
|
||||
const preset = btn.getAttribute('data-preset');
|
||||
this._handlePresetClick(preset);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.cancelBtn) {
|
||||
this.cancelBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._handleCancel();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.applyBtn) {
|
||||
this.applyBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._handleApply();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.backdrop) {
|
||||
this.backdrop.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._handleCancel();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', () => {
|
||||
if (this.isOpen) {
|
||||
this._handleCancel();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && this.isOpen) {
|
||||
this._handleCancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_bindDisplayInputs() {
|
||||
const parseDate = (val) => {
|
||||
const m = val.trim().match(/^(\d{1,2})[\/\-.](\d{1,2})[\/\-.](\d{4})$/);
|
||||
if (!m) return null;
|
||||
const d = parseInt(m[1], 10);
|
||||
const mon = parseInt(m[2], 10) - 1;
|
||||
const y = parseInt(m[3], 10);
|
||||
|
||||
if (mon < 0 || mon > 11 || d < 1 || d > 31 || y < 1000 || y > 9999) return null;
|
||||
|
||||
const testDate = new Date(y, mon, d);
|
||||
if (testDate.getFullYear() !== y || testDate.getMonth() !== mon || testDate.getDate() !== d) {
|
||||
return null;
|
||||
}
|
||||
return testDate;
|
||||
};
|
||||
|
||||
const setupInput = (input, isStart) => {
|
||||
if (!input) return;
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
const CTRL_KEYS = new Set(['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'Enter']);
|
||||
if (!/^\d$/.test(e.key) && e.key !== '/' && e.key !== '-' && e.key !== '.' && !CTRL_KEYS.has(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('input', (e) => {
|
||||
if (e.inputType && e.inputType.startsWith('delete')) {
|
||||
return;
|
||||
}
|
||||
|
||||
let val = input.value.replace(/[^\d]/g, '');
|
||||
let formatted = val;
|
||||
if (val.length > 2) {
|
||||
formatted = val.substring(0, 2) + '/' + val.substring(2);
|
||||
}
|
||||
if (val.length > 4) {
|
||||
formatted = val.substring(0, 2) + '/' + val.substring(2, 4) + '/' + val.substring(4, 8);
|
||||
}
|
||||
input.value = formatted;
|
||||
|
||||
const parsed = parseDate(formatted);
|
||||
if (parsed) {
|
||||
const today = new Date();
|
||||
today.setHours(23, 59, 59, 999);
|
||||
if (parsed <= today) {
|
||||
if (isStart) {
|
||||
this.tempStartDate = parsed;
|
||||
if (this.tempEndDate && this.tempEndDate < this.tempStartDate) {
|
||||
this.tempEndDate = new Date(this.tempStartDate);
|
||||
}
|
||||
} else {
|
||||
this.tempEndDate = parsed;
|
||||
if (this.tempStartDate && this.tempStartDate > this.tempEndDate) {
|
||||
this.tempStartDate = new Date(this.tempEndDate);
|
||||
}
|
||||
}
|
||||
|
||||
this.currentLeftMonth = parsed.getMonth();
|
||||
this.currentLeftYear = parsed.getFullYear();
|
||||
|
||||
const rightMY = this._getRightMonthYear();
|
||||
const isEn = this._isEnglish();
|
||||
const monthNames = isEn ?
|
||||
['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] :
|
||||
['Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'];
|
||||
if (this.leftMonthName) this.leftMonthName.textContent = monthNames[this.currentLeftMonth];
|
||||
if (this.leftYearSelect) this.leftYearSelect.value = this.currentLeftYear;
|
||||
if (this.rightMonthName) this.rightMonthName.textContent = monthNames[rightMY.month];
|
||||
if (this.rightYearSelect) this.rightYearSelect.value = rightMY.year;
|
||||
if (this.leftDaysContainer) this._populateDaysContainer(this.leftDaysContainer, this.currentLeftMonth, this.currentLeftYear);
|
||||
if (this.rightDaysContainer) this._populateDaysContainer(this.rightDaysContainer, rightMY.month, rightMY.year);
|
||||
this._updatePresetSelectionVisuals();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => {
|
||||
const parsed = parseDate(input.value);
|
||||
if (!parsed) {
|
||||
if (isStart) {
|
||||
input.value = this._formatDateDisplay(this.tempStartDate);
|
||||
} else {
|
||||
input.value = this._formatDateDisplay(this.tempEndDate);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
setupInput(this.startDisplay, true);
|
||||
setupInput(this.endDisplay, false);
|
||||
}
|
||||
|
||||
_populateYearSelects() {
|
||||
if (!this.leftYearSelect || !this.rightYearSelect) return;
|
||||
|
||||
this.leftYearSelect.innerHTML = '';
|
||||
this.rightYearSelect.innerHTML = '';
|
||||
|
||||
const startYear = 2020;
|
||||
const endYear = new Date().getFullYear() + 4;
|
||||
|
||||
for (let y = startYear; y <= endYear; y++) {
|
||||
const optLeft = document.createElement('option');
|
||||
optLeft.value = y;
|
||||
optLeft.textContent = y;
|
||||
this.leftYearSelect.appendChild(optLeft);
|
||||
|
||||
const optRight = document.createElement('option');
|
||||
optRight.value = y;
|
||||
optRight.textContent = y;
|
||||
this.rightYearSelect.appendChild(optRight);
|
||||
}
|
||||
|
||||
this.leftYearSelect.addEventListener('change', () => {
|
||||
this._handleYearChange(parseInt(this.leftYearSelect.value, 10), true);
|
||||
});
|
||||
|
||||
this.rightYearSelect.addEventListener('change', () => {
|
||||
this._handleYearChange(parseInt(this.rightYearSelect.value, 10), false);
|
||||
});
|
||||
}
|
||||
|
||||
_handleYearChange(year, isLeft) {
|
||||
if (isLeft) {
|
||||
this.currentLeftYear = year;
|
||||
} else {
|
||||
const rightMY = this._getRightMonthYear();
|
||||
const rightMonth = rightMY.month;
|
||||
if (rightMonth === 0) {
|
||||
this.currentLeftYear = year - 1;
|
||||
} else {
|
||||
this.currentLeftYear = year;
|
||||
}
|
||||
}
|
||||
this._renderCalendars();
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
document.querySelectorAll('.custom-dropdown.active').forEach(dd => {
|
||||
dd.classList.remove('active');
|
||||
dd.querySelector('.custom-dropdown-trigger')?.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
this.isOpen = true;
|
||||
this.container.classList.add('active');
|
||||
this.trigger.setAttribute('aria-expanded', 'true');
|
||||
|
||||
this.tempStartDate = this.startDate ? new Date(this.startDate) : null;
|
||||
this.tempEndDate = this.endDate ? new Date(this.endDate) : null;
|
||||
|
||||
const today = new Date();
|
||||
if (this.endDate) {
|
||||
this.currentLeftMonth = this.endDate.getMonth();
|
||||
this.currentLeftYear = this.endDate.getFullYear();
|
||||
} else {
|
||||
this.currentLeftMonth = today.getMonth();
|
||||
this.currentLeftYear = today.getFullYear();
|
||||
}
|
||||
|
||||
this._renderCalendars();
|
||||
|
||||
if (window.innerWidth <= 768) {
|
||||
document.body.classList.add('date-picker-open');
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.container.classList.remove('active');
|
||||
this.trigger.setAttribute('aria-expanded', 'false');
|
||||
document.body.classList.remove('date-picker-open');
|
||||
}
|
||||
|
||||
_handleCancel() {
|
||||
this.tempStartDate = this.startDate ? new Date(this.startDate) : null;
|
||||
this.tempEndDate = this.endDate ? new Date(this.endDate) : null;
|
||||
this.close();
|
||||
}
|
||||
|
||||
_handleApply() {
|
||||
this.startDate = this.tempStartDate;
|
||||
this.endDate = this.tempEndDate;
|
||||
|
||||
this._updateTriggerLabel();
|
||||
this.close();
|
||||
|
||||
const startStr = this._getDateString(this.startDate);
|
||||
const endStr = this._getDateString(this.endDate);
|
||||
if (this.onChange) {
|
||||
this.onChange(startStr, endStr);
|
||||
}
|
||||
}
|
||||
|
||||
_getDateString(date) {
|
||||
if (!date) return '';
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { DirectusClient } from '../services/directus-client.js';
|
||||
import { FileDownloader } from '../services/file-downloader.js';
|
||||
import { DocumentFormatter } from '../utils/document-formatter.js';
|
||||
|
||||
export class DocumentCardView {
|
||||
constructor(item) {
|
||||
this.item = item;
|
||||
this.title = item.nama_berkas;
|
||||
this.tags = DocumentFormatter.parseTags(item.tag);
|
||||
this.category = DocumentFormatter.categoryFromTags(this.tags);
|
||||
this.fileInfo = DocumentFormatter.fileInfoFromItem(item);
|
||||
this.fileType = this.fileInfo.type;
|
||||
this.assetUrl = DirectusClient.assetUrl(item.file);
|
||||
this.viewerUrl = DirectusClient.viewerUrl(item);
|
||||
this.downloadName = DocumentFormatter.downloadFilenameFromItem(item);
|
||||
this.dateIso = item.tanggal_upload || '';
|
||||
this.dateKey = this.dateIso ? this.dateIso.substring(0, 10) : '';
|
||||
}
|
||||
|
||||
render() {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'document-card';
|
||||
card.dataset.date = this.dateKey;
|
||||
|
||||
const typeUpper = this.fileType.toUpperCase();
|
||||
const typeClass = this.fileInfo.css;
|
||||
const iconClass = this.fileInfo.icon;
|
||||
const formattedDate = DocumentFormatter.formatDate(this.dateIso);
|
||||
|
||||
const safeTitle = DocumentFormatter.escapeHtml(this.title);
|
||||
const safeViewerUrl = DocumentFormatter.escapeAttr(this.viewerUrl);
|
||||
const safeAssetUrl = DocumentFormatter.escapeAttr(this.assetUrl);
|
||||
|
||||
// Build tag badges HTML.
|
||||
const tagBadgesHtml = this.tags.length
|
||||
? `<div class="doc-tags"><i class="fas fa-tags doc-tags-icon"></i>${this.tags.map(t => `<span class="doc-tag">${DocumentFormatter.escapeHtml(DocumentFormatter.formatTag(t))}</span>`).join('')}</div>`
|
||||
: '';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="doc-type-icon ${typeClass}"><i class="${iconClass}"></i></div>
|
||||
<h3 class="doc-title">${safeTitle}</h3>
|
||||
<div class="doc-meta">
|
||||
<span class="doc-badge">${typeUpper}</span>
|
||||
<span class="doc-badge date"><i class="far fa-calendar-alt"></i> ${formattedDate}</span>
|
||||
</div>
|
||||
${tagBadgesHtml}
|
||||
<div class="doc-actions">
|
||||
<a href="${safeViewerUrl}" class="btn-action-primary" target="_blank" rel="noopener"><span data-tp-i18n="card_open">Buka Berkas</span> <i class="fas fa-external-link-alt"></i></a>
|
||||
<a href="${safeAssetUrl}" class="btn-action-outline"><span data-tp-i18n="card_download">Unduh Berkas</span> <i class="fas fa-download"></i></a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
card.querySelector('.btn-action-outline').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
FileDownloader.download(this.assetUrl, this.downloadName);
|
||||
});
|
||||
|
||||
// Prefetch the asset when the user hovers over the card so the
|
||||
// browser cache already has the file by the time the viewer loads.
|
||||
let prefetched = false;
|
||||
card.addEventListener('mouseenter', () => {
|
||||
if (prefetched) return;
|
||||
prefetched = true;
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'prefetch';
|
||||
link.as = 'fetch';
|
||||
link.crossOrigin = 'anonymous';
|
||||
link.href = this.assetUrl;
|
||||
document.head.appendChild(link);
|
||||
}, { once: true });
|
||||
|
||||
return card;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export class AccessibilityToggleController {
|
||||
constructor({
|
||||
triggerSelector = '[data-accessibility-toggle]',
|
||||
panelId = 'access-panel',
|
||||
} = {}) {
|
||||
this.triggers = Array.from(document.querySelectorAll(triggerSelector));
|
||||
this.panel = document.getElementById(panelId);
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.triggers.length === 0) return;
|
||||
this.triggers.forEach(trigger => {
|
||||
trigger.addEventListener('click', () => this.toggle());
|
||||
});
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (!this.panel) return;
|
||||
this.panel.classList.toggle('hidden');
|
||||
this.panel.classList.toggle('flex');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import { DirectusClient } from '../services/directus-client.js';
|
||||
import { DocumentCardView } from '../components/document-card-view.js';
|
||||
import { CustomDropdown } from '../components/custom-dropdown.js';
|
||||
import { DateRangePicker } from '../components/date-range-picker.js';
|
||||
import { CounterAnimation } from '../components/counter-animation.js';
|
||||
|
||||
export class DocumentRepository {
|
||||
static PAGE_SIZE = 6;
|
||||
|
||||
constructor() {
|
||||
this.gridContainer = document.getElementById('document-grid');
|
||||
this.filterWrapper = document.getElementById('tag-filter-wrapper');
|
||||
this.mobileFilterWrapper = document.getElementById('mobile-tag-filter-wrapper');
|
||||
this.searchInput = document.getElementById('doc-search');
|
||||
this.sortFilterWrapper = document.getElementById('sort-filter-wrapper');
|
||||
|
||||
// Hero stat values (filled from real API data after fetch).
|
||||
this.statActiveEl = document.getElementById('stat-active-value');
|
||||
this.statUpdatesEl = document.getElementById('stat-updates-value');
|
||||
|
||||
this.cards = [];
|
||||
this.paginationContainer = null;
|
||||
this.state = {
|
||||
filter: 'all', // 'all' or a specific tag string
|
||||
search: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
sort: 'newest',
|
||||
page: 1,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
async init() {
|
||||
if (!this.gridContainer) return;
|
||||
this._bindControls();
|
||||
this._initPagination();
|
||||
try {
|
||||
const items = await DirectusClient.fetchPublished('berkas');
|
||||
this._renderCards(items);
|
||||
this._buildCategoryFilters(items);
|
||||
this._renderStats(items);
|
||||
this._applySort();
|
||||
this._applyFilters();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
this._renderError();
|
||||
this._renderStatsError();
|
||||
}
|
||||
}
|
||||
|
||||
_initPagination() {
|
||||
const pag = document.createElement('div');
|
||||
pag.id = 'doc-pagination';
|
||||
pag.className = 'doc-pagination';
|
||||
this.gridContainer.insertAdjacentElement('afterend', pag);
|
||||
this.paginationContainer = pag;
|
||||
}
|
||||
|
||||
// Days within which an upload counts as a "recent update".
|
||||
static RECENT_DAYS = 30;
|
||||
|
||||
// Populate the hero stats from real data:
|
||||
// Dokumen Aktif → number of published documents
|
||||
// Pembaruan Terbaru → uploads within the last RECENT_DAYS days
|
||||
_renderStats(items) {
|
||||
const active = items.length;
|
||||
|
||||
const windowMs = DocumentRepository.RECENT_DAYS * 24 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const recent = items.filter(item => {
|
||||
if (!item.tanggal_upload) return false;
|
||||
const t = new Date(item.tanggal_upload).getTime();
|
||||
return !Number.isNaN(t) && now - t <= windowMs;
|
||||
}).length;
|
||||
|
||||
CounterAnimation.animateValue(this.statActiveEl, active);
|
||||
CounterAnimation.animateValue(this.statUpdatesEl, recent);
|
||||
}
|
||||
|
||||
_renderStatsError() {
|
||||
if (this.statActiveEl) this.statActiveEl.textContent = '—';
|
||||
if (this.statUpdatesEl) this.statUpdatesEl.textContent = '—';
|
||||
}
|
||||
/**
|
||||
* Fixed category definitions for the sidebar filter.
|
||||
* Each has a key, an i18n key for bilingual labels, and a fallback label.
|
||||
*/
|
||||
static CATEGORIES = [
|
||||
{ key: 'all', i18n: 'filter_all', label: 'Semua Berkas' },
|
||||
{ key: 'skripsi', i18n: 'filter_skripsi', label: 'Panduan Skripsi' },
|
||||
{ key: 'akademik', i18n: 'filter_akademik', label: 'Kalender Akademik' },
|
||||
{ key: 'praktik', i18n: 'filter_praktik', label: 'Kerja Praktik' },
|
||||
{ key: 'umum', i18n: 'filter_umum', label: 'Formulir Umum' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Build category filter controls:
|
||||
* – Desktop: sidebar buttons (hidden on mobile via CSS)
|
||||
* – Mobile: a <select> dropdown (hidden on desktop via CSS)
|
||||
* Both stay in sync through a shared _setFilter() helper.
|
||||
*/
|
||||
_buildCategoryFilters(items) {
|
||||
if (!this.filterWrapper) return;
|
||||
|
||||
// Clear skeleton placeholder.
|
||||
this.filterWrapper.innerHTML = '';
|
||||
|
||||
// ── Desktop buttons ──
|
||||
const btnGroup = document.createElement('div');
|
||||
btnGroup.className = 'filter-btn-group';
|
||||
|
||||
DocumentRepository.CATEGORIES.forEach(({ key, i18n, label }) => {
|
||||
const isActive = key === 'all';
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'doc-filter-btn' + (isActive ? ' active' : '');
|
||||
btn.dataset.filter = key;
|
||||
btn.textContent = (window.tpT?.(i18n)) || label;
|
||||
btn.setAttribute('data-tp-i18n', i18n);
|
||||
|
||||
btn.addEventListener('click', () => this._setFilter(key));
|
||||
btnGroup.appendChild(btn);
|
||||
});
|
||||
|
||||
this.filterWrapper.appendChild(btnGroup);
|
||||
|
||||
// ── Mobile Custom UI Dropdown ──
|
||||
if (this.mobileFilterWrapper) {
|
||||
const dropdownOptions = DocumentRepository.CATEGORIES.map(({ key, i18n, label }) => ({
|
||||
value: key,
|
||||
i18nKey: i18n,
|
||||
text: (window.tpT?.(i18n)) || label
|
||||
}));
|
||||
|
||||
this._mobileDropdown = new CustomDropdown({
|
||||
container: this.mobileFilterWrapper,
|
||||
options: dropdownOptions,
|
||||
defaultValue: this.state.filter,
|
||||
onChange: (value) => this._setFilter(value)
|
||||
});
|
||||
}
|
||||
|
||||
// Store refs for syncing.
|
||||
this._filterBtnGroup = btnGroup;
|
||||
|
||||
// Re-translate if i18n is active.
|
||||
window.applyTp?.(this.filterWrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared filter setter — keeps buttons + dropdown in sync.
|
||||
*/
|
||||
_setFilter(key) {
|
||||
this.state.filter = key;
|
||||
this.state.page = 1;
|
||||
|
||||
// Sync buttons.
|
||||
if (this._filterBtnGroup) {
|
||||
this._filterBtnGroup.querySelectorAll('.doc-filter-btn')
|
||||
.forEach(b => b.classList.toggle('active', b.dataset.filter === key));
|
||||
}
|
||||
|
||||
// Sync custom dropdown.
|
||||
if (this._mobileDropdown && this._mobileDropdown.getValue() !== key) {
|
||||
this._mobileDropdown.setValue(key);
|
||||
}
|
||||
|
||||
this._applyFilters();
|
||||
}
|
||||
|
||||
_bindControls() {
|
||||
this.searchInput?.addEventListener('input', (e) => {
|
||||
this.state.search = e.target.value.toLowerCase().trim();
|
||||
this.state.page = 1;
|
||||
this._applyFilters();
|
||||
});
|
||||
|
||||
// Initialize Custom Date Range Picker
|
||||
const datePickerGroup = document.getElementById('date-range-control-group');
|
||||
if (datePickerGroup) {
|
||||
this.dateRangePicker = new DateRangePicker({
|
||||
containerId: 'date-range-control-group',
|
||||
onChange: (startDate, endDate) => {
|
||||
this.state.startDate = startDate;
|
||||
this.state.endDate = endDate;
|
||||
this.state.page = 1;
|
||||
this._applyFilters();
|
||||
}
|
||||
});
|
||||
window.applyTp?.(datePickerGroup);
|
||||
}
|
||||
|
||||
if (this.sortFilterWrapper) {
|
||||
const sortOptions = [
|
||||
{ value: 'newest', i18nKey: 'sort_newest', text: (window.tpT?.('sort_newest')) || 'Terbaru' },
|
||||
{ value: 'oldest', i18nKey: 'sort_oldest', text: (window.tpT?.('sort_oldest')) || 'Terlama' },
|
||||
{ value: 'az', i18nKey: 'sort_az', text: (window.tpT?.('sort_az')) || 'Nama A-Z' }
|
||||
];
|
||||
|
||||
this._sortDropdown = new CustomDropdown({
|
||||
container: this.sortFilterWrapper,
|
||||
options: sortOptions,
|
||||
defaultValue: this.state.sort,
|
||||
prefixIcon: 'fas fa-sort-amount-down',
|
||||
onChange: (value) => {
|
||||
this.state.sort = value;
|
||||
this.state.page = 1;
|
||||
this._applySort();
|
||||
this._applyFilters();
|
||||
}
|
||||
});
|
||||
|
||||
window.applyTp?.(this.sortFilterWrapper);
|
||||
}
|
||||
}
|
||||
|
||||
_renderCards(items) {
|
||||
this.gridContainer.innerHTML = '';
|
||||
|
||||
if (items.length === 0) {
|
||||
this._renderState('repo-empty', 'fas fa-folder-open', '#cbd5e1', '#64748b',
|
||||
'state_empty', 'Belum ada dokumen yang tersedia di repositori.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.cards = items.map(item => {
|
||||
const view = new DocumentCardView(item);
|
||||
const element = view.render();
|
||||
this.gridContainer.appendChild(element);
|
||||
return { view, element };
|
||||
});
|
||||
|
||||
// Translate the freshly-rendered card labels (Buka/Unduh Berkas).
|
||||
window.applyTp?.(this.gridContainer);
|
||||
}
|
||||
|
||||
_renderError() {
|
||||
this._renderState('repo-error', 'fas fa-exclamation-triangle', '#f87171', '#ef4444',
|
||||
'state_error', 'Gagal memuat berkas. Silakan periksa koneksi internet Anda atau coba lagi nanti.');
|
||||
}
|
||||
|
||||
// Build the empty/error placeholder via safe DOM APIs (no innerHTML).
|
||||
// `i18nKey` lets the Tapops i18n layer re-translate the message on toggle.
|
||||
_renderState(className, iconClass, iconColor, textColor, i18nKey, message) {
|
||||
this.cards = [];
|
||||
this.gridContainer.innerHTML = '';
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = className;
|
||||
Object.assign(wrapper.style, {
|
||||
gridColumn: '1 / -1',
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: textColor,
|
||||
fontWeight: '700',
|
||||
fontSize: '1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '15px',
|
||||
});
|
||||
|
||||
const icon = document.createElement('i');
|
||||
icon.className = iconClass;
|
||||
Object.assign(icon.style, { fontSize: '3rem', color: iconColor });
|
||||
|
||||
const span = document.createElement('span');
|
||||
if (i18nKey) span.setAttribute('data-tp-i18n', i18nKey);
|
||||
span.textContent = (window.tpT?.(i18nKey)) || message;
|
||||
|
||||
wrapper.appendChild(icon);
|
||||
wrapper.appendChild(span);
|
||||
this.gridContainer.appendChild(wrapper);
|
||||
}
|
||||
|
||||
_applyFilters() {
|
||||
const matched = this.cards.filter(({ view }) => this._cardMatches(view));
|
||||
const total = matched.length;
|
||||
|
||||
// Handle zero-results state when cards exist but none match the filter.
|
||||
this._toggleNoResultsState(total === 0 && this.cards.length > 0);
|
||||
|
||||
if (total === 0) {
|
||||
this.cards.forEach(({ element }) => { element.style.display = 'none'; });
|
||||
this._renderPagination(0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / DocumentRepository.PAGE_SIZE);
|
||||
|
||||
// Clamp page to valid range after filter changes.
|
||||
this.state.page = Math.min(Math.max(this.state.page, 1), totalPages);
|
||||
|
||||
const start = (this.state.page - 1) * DocumentRepository.PAGE_SIZE;
|
||||
const pageSet = new Set(
|
||||
matched.slice(start, start + DocumentRepository.PAGE_SIZE).map(c => c.element)
|
||||
);
|
||||
|
||||
this.cards.forEach(({ element }) => {
|
||||
const show = pageSet.has(element);
|
||||
element.style.display = show ? 'flex' : 'none';
|
||||
if (show) {
|
||||
setTimeout(() => {
|
||||
element.style.opacity = '1';
|
||||
element.style.transform = 'translateY(0)';
|
||||
}, 10);
|
||||
} else {
|
||||
element.style.opacity = '0';
|
||||
element.style.transform = 'translateY(10px)';
|
||||
}
|
||||
});
|
||||
|
||||
this._renderPagination(totalPages, total);
|
||||
}
|
||||
|
||||
_toggleNoResultsState(show) {
|
||||
const existing = this.gridContainer.querySelector('.repo-no-results');
|
||||
if (show && !existing) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'repo-no-results';
|
||||
Object.assign(wrapper.style, {
|
||||
gridColumn: '1 / -1',
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#64748b',
|
||||
fontWeight: '700',
|
||||
fontSize: '1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '15px',
|
||||
});
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'fas fa-search';
|
||||
Object.assign(icon.style, { fontSize: '3rem', color: '#cbd5e1' });
|
||||
const span = document.createElement('span');
|
||||
span.setAttribute('data-tp-i18n', 'state_no_results');
|
||||
span.textContent = (window.tpT?.('state_no_results')) || 'Tidak ada berkas yang cocok dengan filter saat ini.';
|
||||
wrapper.appendChild(icon);
|
||||
wrapper.appendChild(span);
|
||||
this.gridContainer.appendChild(wrapper);
|
||||
} else if (!show && existing) {
|
||||
existing.remove();
|
||||
}
|
||||
}
|
||||
|
||||
_renderPagination(totalPages, total) {
|
||||
if (!this.paginationContainer) return;
|
||||
this.paginationContainer.innerHTML = '';
|
||||
if (total === 0 || totalPages <= 1) return;
|
||||
|
||||
const current = this.state.page;
|
||||
const start = (current - 1) * DocumentRepository.PAGE_SIZE + 1;
|
||||
const end = Math.min(current * DocumentRepository.PAGE_SIZE, total);
|
||||
|
||||
const info = document.createElement('span');
|
||||
info.className = 'pagination-info';
|
||||
info.textContent = `${start}–${end} dari ${total} berkas`;
|
||||
this.paginationContainer.appendChild(info);
|
||||
|
||||
const nav = document.createElement('nav');
|
||||
nav.className = 'pagination-nav';
|
||||
nav.setAttribute('aria-label', 'Navigasi halaman dokumen');
|
||||
|
||||
const prev = document.createElement('button');
|
||||
prev.className = 'pagination-btn' + (current <= 1 ? ' disabled' : '');
|
||||
prev.disabled = current <= 1;
|
||||
prev.setAttribute('aria-label', 'Halaman sebelumnya');
|
||||
const prevIcon = document.createElement('i');
|
||||
prevIcon.className = 'fas fa-chevron-left';
|
||||
prev.appendChild(prevIcon);
|
||||
prev.addEventListener('click', () => {
|
||||
if (this.state.page > 1) { this.state.page--; this._applyFilters(); this._scrollToGrid(); }
|
||||
});
|
||||
nav.appendChild(prev);
|
||||
|
||||
this._paginationRange(current, totalPages).forEach(p => {
|
||||
if (p === '...') {
|
||||
const el = document.createElement('span');
|
||||
el.className = 'pagination-ellipsis';
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
el.textContent = '…';
|
||||
nav.appendChild(el);
|
||||
} else {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'pagination-btn pagination-number' + (p === current ? ' active' : '');
|
||||
btn.textContent = p;
|
||||
btn.setAttribute('aria-label', `Halaman ${p}`);
|
||||
if (p === current) btn.setAttribute('aria-current', 'page');
|
||||
btn.addEventListener('click', () => {
|
||||
this.state.page = p; this._applyFilters(); this._scrollToGrid();
|
||||
});
|
||||
nav.appendChild(btn);
|
||||
}
|
||||
});
|
||||
|
||||
const next = document.createElement('button');
|
||||
next.className = 'pagination-btn' + (current >= totalPages ? ' disabled' : '');
|
||||
next.disabled = current >= totalPages;
|
||||
next.setAttribute('aria-label', 'Halaman berikutnya');
|
||||
const nextIcon = document.createElement('i');
|
||||
nextIcon.className = 'fas fa-chevron-right';
|
||||
next.appendChild(nextIcon);
|
||||
next.addEventListener('click', () => {
|
||||
if (this.state.page < totalPages) { this.state.page++; this._applyFilters(); this._scrollToGrid(); }
|
||||
});
|
||||
nav.appendChild(next);
|
||||
|
||||
this.paginationContainer.appendChild(nav);
|
||||
}
|
||||
|
||||
// Returns page numbers and '...' ellipsis placeholders for the given range.
|
||||
_paginationRange(current, total) {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
||||
|
||||
const pages = [1];
|
||||
const lo = Math.max(2, current - 1);
|
||||
const hi = Math.min(total - 1, current + 1);
|
||||
|
||||
if (lo > 2) pages.push('...');
|
||||
for (let i = lo; i <= hi; i++) pages.push(i);
|
||||
if (hi < total - 1) pages.push('...');
|
||||
pages.push(total);
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
_scrollToGrid() {
|
||||
document.getElementById('repository')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
_cardMatches(view) {
|
||||
const { filter, search } = this.state;
|
||||
const matchesCategory = filter === 'all' || view.category === filter;
|
||||
const matchesTitle = view.title.toLowerCase().includes(search);
|
||||
const matchesTags = search
|
||||
? view.tags.some(t => t.toLowerCase().includes(search))
|
||||
: false;
|
||||
const matchesSearch = matchesTitle || matchesTags;
|
||||
return matchesCategory && matchesSearch && this._dateMatches(view.dateKey);
|
||||
}
|
||||
|
||||
_dateMatches(dateKey) {
|
||||
const { startDate, endDate } = this.state;
|
||||
if (!dateKey) return !startDate && !endDate;
|
||||
const cardDate = new Date(dateKey);
|
||||
if (startDate && cardDate < new Date(startDate)) return false;
|
||||
if (endDate) {
|
||||
const end = new Date(endDate);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
if (cardDate > end) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_applySort() {
|
||||
const { sort } = this.state;
|
||||
this.cards.sort((a, b) => {
|
||||
if (sort === 'newest') return b.view.dateKey.localeCompare(a.view.dateKey);
|
||||
if (sort === 'oldest') return a.view.dateKey.localeCompare(b.view.dateKey);
|
||||
if (sort === 'az') return a.view.title.toLowerCase().localeCompare(b.view.title.toLowerCase());
|
||||
return 0;
|
||||
});
|
||||
this.cards.forEach(({ element }) => this.gridContainer.appendChild(element));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export class MobileMenuController {
|
||||
constructor({
|
||||
buttonId = 'mobile-menu-button',
|
||||
menuId = 'mobile-menu',
|
||||
closeButtonId = 'close-mobile-menu',
|
||||
} = {}) {
|
||||
this.button = document.getElementById(buttonId);
|
||||
this.menu = document.getElementById(menuId);
|
||||
this.closeButton = document.getElementById(closeButtonId);
|
||||
}
|
||||
|
||||
init() {
|
||||
if (!this.button || !this.menu) return;
|
||||
|
||||
this.button.addEventListener('click', () => this.toggle());
|
||||
this.closeButton?.addEventListener('click', () => this.close());
|
||||
}
|
||||
|
||||
toggle() {
|
||||
const isOpen = !this.menu.classList.contains('-translate-x-full');
|
||||
this.menu.classList.toggle('-translate-x-full');
|
||||
this.button.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
|
||||
}
|
||||
|
||||
close() {
|
||||
this.menu.classList.add('-translate-x-full');
|
||||
this.button?.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
export class NavigationController {
|
||||
constructor({
|
||||
menuSelector = '[data-menu]',
|
||||
overlayId = 'mega-overlay',
|
||||
} = {}) {
|
||||
this.menuItems = Array.from(document.querySelectorAll(menuSelector));
|
||||
this.overlay = document.getElementById(overlayId);
|
||||
this.activeMenu = null;
|
||||
this.menuMap = {
|
||||
tentang: 'mega-tentang',
|
||||
program: 'mega-program',
|
||||
sdm: 'mega-sdm',
|
||||
fasilitas: 'mega-fasilitas',
|
||||
karya: 'mega-karya',
|
||||
kontak: 'mega-kontak',
|
||||
};
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.menuItems.length === 0 || !this.overlay) return;
|
||||
|
||||
this.menuItems.forEach(item => {
|
||||
const menuId = item.getAttribute('data-menu');
|
||||
const button = item.querySelector('.nav-menu-btn');
|
||||
if (!button) return;
|
||||
|
||||
button.setAttribute('aria-haspopup', 'true');
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
button.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (this.activeMenu === menuId) {
|
||||
this.closeMenus();
|
||||
} else {
|
||||
this.openMenu(menuId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.overlay.addEventListener('click', () => this.closeMenus());
|
||||
document.addEventListener('click', event => this._handleOutsideClick(event));
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && this.activeMenu) this.closeMenus();
|
||||
});
|
||||
}
|
||||
|
||||
openMenu(menuId) {
|
||||
const targetPanel = document.getElementById(this.menuMap[menuId]);
|
||||
if (!targetPanel) return;
|
||||
|
||||
Object.values(this.menuMap).forEach(panelId => {
|
||||
document.getElementById(panelId)?.classList.remove('active');
|
||||
});
|
||||
|
||||
targetPanel.classList.add('active');
|
||||
this.overlay.classList.add('active');
|
||||
this._setButtonState(menuId);
|
||||
this.activeMenu = menuId;
|
||||
}
|
||||
|
||||
closeMenus() {
|
||||
Object.values(this.menuMap).forEach(panelId => {
|
||||
document.getElementById(panelId)?.classList.remove('active');
|
||||
});
|
||||
|
||||
this.overlay.classList.remove('active');
|
||||
this._setButtonState(null);
|
||||
this.activeMenu = null;
|
||||
}
|
||||
|
||||
_setButtonState(menuId) {
|
||||
this.menuItems.forEach(item => {
|
||||
const isActive = item.getAttribute('data-menu') === menuId;
|
||||
const button = item.querySelector('.nav-menu-btn');
|
||||
const chevron = item.querySelector('.fa-chevron-down');
|
||||
|
||||
button?.classList.toggle('active', isActive);
|
||||
button?.setAttribute('aria-expanded', isActive ? 'true' : 'false');
|
||||
if (chevron) chevron.style.transform = isActive ? 'rotate(180deg)' : '';
|
||||
});
|
||||
}
|
||||
|
||||
_handleOutsideClick(event) {
|
||||
const clickedInsideNav = event.target.closest('#main-nav');
|
||||
const clickedInsidePanel = event.target.closest('.mega-panel');
|
||||
if (!clickedInsideNav && !clickedInsidePanel && this.activeMenu) this.closeMenus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Tapops-local i18n — bilingual ID/EN for the document-repository sections.
|
||||
*
|
||||
* The shared assets/global/localization.js owns the nav/footer translations
|
||||
* and exposes window.getLang() plus a `languageChanged` event. Its internal
|
||||
* `translations` object is not extendable from here, so this layer keeps its
|
||||
* own dictionary under a separate `data-tp-i18n*` attribute namespace. The
|
||||
* global applyLang() ignores those attributes, and this script ignores the
|
||||
* global `data-i18n*` ones — no collisions.
|
||||
*
|
||||
* Exposes window.applyTp() and window.tpT(key) so main.js can translate the
|
||||
* dynamically-rendered document cards and state messages too.
|
||||
*/
|
||||
(function () {
|
||||
const dict = {
|
||||
id: {
|
||||
// Hero
|
||||
hero_kicker: "Digital Administrative Hub",
|
||||
hero_title: "Administrasi Akademik Digital",
|
||||
hero_tagline: "Satu pintu menuju semua dokumen penting. Akses terpusat menuju repositori dokumen akademik Program Studi Informatika Universitas Tanjungpura untuk mahasiswa dan staf.",
|
||||
hero_btn_search: "Cari Dokumen",
|
||||
hero_btn_flow: "ALUR PENGAJUAN",
|
||||
stat_active: "Dokumen Aktif",
|
||||
stat_updates: "Pembaruan Terbaru",
|
||||
|
||||
// Repository — sidebar
|
||||
sidebar_category: "KATEGORI BERKAS",
|
||||
filter_all: "Semua Berkas",
|
||||
filter_skripsi: "Panduan Skripsi",
|
||||
filter_akademik: "Kalender Akademik",
|
||||
filter_praktik: "Kerja Praktik",
|
||||
filter_umum: "Formulir Umum",
|
||||
sidebar_info: "Pastikan mengunduh versi terbaru untuk menjamin validitas berkas pengajuan.",
|
||||
|
||||
// Repository — controls
|
||||
repo_title: "Eksplorasi Dokumen Prodi",
|
||||
repo_subtitle: "Temukan berkas administrasi dan akademik terbaru Program Studi Informatika.",
|
||||
search_ph: "Cari nama berkas atau kata kunci...",
|
||||
label_period: "Periode Unggahan",
|
||||
range_divider: "s/d",
|
||||
label_sort: "Urutkan",
|
||||
sort_newest: "Terbaru",
|
||||
sort_oldest: "Terlama",
|
||||
sort_az: "Nama A-Z",
|
||||
loading: "Menghubungkan ke repositori berkas...",
|
||||
filter_all_time: "Semua Waktu",
|
||||
presets_title: "RENTANG WAKTU",
|
||||
preset_today: "Hari Ini",
|
||||
preset_yesterday: "Kemarin",
|
||||
preset_last_7_days: "7 Hari Terakhir",
|
||||
preset_last_14_days: "14 Hari Terakhir",
|
||||
preset_last_30_days: "30 Hari Terakhir",
|
||||
preset_this_week: "Minggu Ini",
|
||||
preset_last_week: "Minggu Lalu",
|
||||
preset_this_month: "Bulan Ini",
|
||||
preset_last_month: "Bulan Lalu",
|
||||
btn_cancel: "Batal",
|
||||
btn_update: "Perbarui",
|
||||
|
||||
// Repository — dynamic (cards + states, used by main.js)
|
||||
card_open: "Buka Berkas",
|
||||
card_download: "Unduh Berkas",
|
||||
state_empty: "Belum ada dokumen yang tersedia di repositori.",
|
||||
state_error: "Gagal memuat berkas. Silakan periksa koneksi internet Anda atau coba lagi nanti.",
|
||||
|
||||
// Procedures
|
||||
proc_title: "Tata Cara Pengajuan Berkas",
|
||||
proc_tag: "Alur Pengajuan",
|
||||
proc_s1_h: "Unduh Formulir",
|
||||
proc_s1_p: "Pilih kategori pengajuan Anda, lalu unduh formulir versi terbaru. Pastikan kode revisi pada formulir sesuai dengan yang tertera di halaman ini.",
|
||||
proc_s2_h: "Lengkapi Formulir",
|
||||
proc_s2_p: "Isi setiap kolom dengan lengkap dan teliti. Sertakan seluruh dokumen pendukung yang diminta sebelum pengiriman.",
|
||||
proc_s3_h: "Kirim Berkas",
|
||||
proc_s3_p: "Kirimkan formulir beserta dokumen pendukung melalui email resmi atau WhatsApp admin prodi. Berkas yang lengkap akan langsung diproses oleh admin prodi.",
|
||||
|
||||
// Help Center
|
||||
help_title: "Butuh Bantuan Lebih Lanjut?",
|
||||
help_desc: "Tim admin kami siap membantu Anda melalui kanal komunikasi resmi di bawah ini.",
|
||||
help_email_title: "Korespondensi Resmi (Email)",
|
||||
help_email_desc: "Gunakan untuk permohonan form khusus non-publik atau administrasi formal lainnya.",
|
||||
help_email_c1: "Respon Maks. 24 Jam",
|
||||
help_email_c2: "Pelayanan Formal",
|
||||
help_email_btn: "HUBUNGI ADMIN",
|
||||
help_wa_title: "Quick Support (WhatsApp)",
|
||||
help_wa_desc: "Konsultasi cepat alur birokrasi atau pelaporan kendala akses pada berkas repositori.",
|
||||
help_wa_c1: "Respon Instan Jam Kerja",
|
||||
help_wa_c2: "Konsultasi Teknis",
|
||||
help_wa_btn: "CHAT WHATSAPP",
|
||||
},
|
||||
en: {
|
||||
// Hero
|
||||
hero_kicker: "Digital Administrative Hub",
|
||||
hero_title: "Digital Academic Administration",
|
||||
hero_tagline: "One gateway to every important document. A centralized access point to the academic document repository of the Informatics Department, Universitas Tanjungpura, for students and staff.",
|
||||
hero_btn_search: "Search Documents",
|
||||
hero_btn_flow: "SUBMISSION FLOW",
|
||||
stat_active: "Active Documents",
|
||||
stat_updates: "Recent Updates",
|
||||
|
||||
// Repository — sidebar
|
||||
sidebar_category: "DOCUMENT CATEGORY",
|
||||
filter_all: "All Files",
|
||||
filter_skripsi: "Thesis Guides",
|
||||
filter_akademik: "Academic Calendar",
|
||||
filter_praktik: "Internship",
|
||||
filter_umum: "General Forms",
|
||||
sidebar_info: "Always download the latest version to ensure your submission files remain valid.",
|
||||
|
||||
// Repository — controls
|
||||
repo_title: "Explore Department Documents",
|
||||
repo_subtitle: "Find the latest administrative and academic files of the Informatics Department.",
|
||||
search_ph: "Search file name or keyword...",
|
||||
label_period: "Upload Period",
|
||||
range_divider: "to",
|
||||
label_sort: "Sort by",
|
||||
sort_newest: "Newest",
|
||||
sort_oldest: "Oldest",
|
||||
sort_az: "Name A-Z",
|
||||
loading: "Connecting to the file repository...",
|
||||
filter_all_time: "All Time",
|
||||
presets_title: "DATE RANGE",
|
||||
preset_today: "Today",
|
||||
preset_yesterday: "Yesterday",
|
||||
preset_last_7_days: "Last 7 Days",
|
||||
preset_last_14_days: "Last 14 Days",
|
||||
preset_last_30_days: "Last 30 Days",
|
||||
preset_this_week: "This Week",
|
||||
preset_last_week: "Last Week",
|
||||
preset_this_month: "This Month",
|
||||
preset_last_month: "Last Month",
|
||||
btn_cancel: "Cancel",
|
||||
btn_update: "Update",
|
||||
|
||||
// Repository — dynamic (cards + states, used by main.js)
|
||||
card_open: "Open File",
|
||||
card_download: "Download File",
|
||||
state_empty: "No documents are available in the repository yet.",
|
||||
state_error: "Failed to load files. Please check your internet connection or try again later.",
|
||||
|
||||
// Procedures
|
||||
proc_title: "Document Submission Procedure",
|
||||
proc_tag: "Submission Flow",
|
||||
proc_s1_h: "Download the Form",
|
||||
proc_s1_p: "Choose your submission category, then download the latest version of the form. Make sure the revision code on the form matches the one shown on this page.",
|
||||
proc_s2_h: "Complete the Form",
|
||||
proc_s2_p: "Fill in every field completely and carefully. Include all required supporting documents before submitting.",
|
||||
proc_s3_h: "Submit the Document",
|
||||
proc_s3_p: "Send the form along with the supporting documents via the official email or the department admin's WhatsApp. Complete submissions are processed immediately by the department admin.",
|
||||
|
||||
// Help Center
|
||||
help_title: "Need Further Assistance?",
|
||||
help_desc: "Our admin team is ready to help you through the official communication channels below.",
|
||||
help_email_title: "Official Correspondence (Email)",
|
||||
help_email_desc: "Use this for requests of non-public special forms or other formal administration.",
|
||||
help_email_c1: "Response within 24 Hours",
|
||||
help_email_c2: "Formal Service",
|
||||
help_email_btn: "CONTACT ADMIN",
|
||||
help_wa_title: "Quick Support (WhatsApp)",
|
||||
help_wa_desc: "Quick consultation on bureaucratic flow or reporting access issues with repository files.",
|
||||
help_wa_c1: "Instant Response in Work Hours",
|
||||
help_wa_c2: "Technical Consultation",
|
||||
help_wa_btn: "CHAT ON WHATSAPP",
|
||||
},
|
||||
};
|
||||
|
||||
function currentLang() {
|
||||
if (typeof window.getLang === 'function') {
|
||||
const l = window.getLang();
|
||||
if (l === 'id' || l === 'en') return l;
|
||||
}
|
||||
try {
|
||||
const saved = localStorage.getItem('if_untan_lang');
|
||||
if (saved === 'id' || saved === 'en') return saved;
|
||||
} catch (e) { /* localStorage unavailable */ }
|
||||
const nav = (navigator.language || 'id').toLowerCase();
|
||||
return nav.startsWith('en') ? 'en' : 'id';
|
||||
}
|
||||
|
||||
function tpT(key) {
|
||||
const lang = currentLang();
|
||||
return (dict[lang] && dict[lang][key]) || dict.id[key] || null;
|
||||
}
|
||||
|
||||
function applyTp(root) {
|
||||
const scope = root || document;
|
||||
|
||||
scope.querySelectorAll('[data-tp-i18n]').forEach(el => {
|
||||
const val = tpT(el.getAttribute('data-tp-i18n'));
|
||||
if (val != null) el.textContent = val;
|
||||
});
|
||||
|
||||
scope.querySelectorAll('[data-tp-i18n-placeholder]').forEach(el => {
|
||||
const val = tpT(el.getAttribute('data-tp-i18n-placeholder'));
|
||||
if (val != null) el.placeholder = val;
|
||||
});
|
||||
}
|
||||
|
||||
// Expose for main.js (dynamic cards/states).
|
||||
window.tpT = tpT;
|
||||
window.applyTp = applyTp;
|
||||
|
||||
// Re-translate when the shared toggle switches language.
|
||||
document.addEventListener('languageChanged', () => applyTp());
|
||||
|
||||
// Initial pass (runs after localization.js's own DOMContentLoaded handler,
|
||||
// so getLang() already reflects the resolved language).
|
||||
document.addEventListener('DOMContentLoaded', () => applyTp());
|
||||
})();
|
||||
@@ -0,0 +1 @@
|
||||
import './app.js';
|
||||
@@ -0,0 +1,16 @@
|
||||
export class PaginationModel {
|
||||
static range(current, total) {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, index) => index + 1);
|
||||
|
||||
const pages = [1];
|
||||
const low = Math.max(2, current - 1);
|
||||
const high = Math.min(total - 1, current + 1);
|
||||
|
||||
if (low > 2) pages.push('...');
|
||||
for (let page = low; page <= high; page += 1) pages.push(page);
|
||||
if (high < total - 1) pages.push('...');
|
||||
pages.push(total);
|
||||
|
||||
return pages;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { DocumentFormatter } from '../utils/document-formatter.js';
|
||||
|
||||
export class DirectusClient {
|
||||
static API_BASE = 'https://api.ifuntanhub.dev';
|
||||
|
||||
static itemsUrl(collection) {
|
||||
const params = new URLSearchParams({
|
||||
limit: '-1',
|
||||
fields: '*,file.id,file.filename_download,file.type,file.filename_disk',
|
||||
});
|
||||
|
||||
return `${this.API_BASE}/items/${collection}?${params.toString()}`;
|
||||
}
|
||||
|
||||
static fileId(file) {
|
||||
return file && typeof file === 'object' ? file.id : file;
|
||||
}
|
||||
|
||||
static assetUrl(file) {
|
||||
return `${this.API_BASE}/assets/${this.fileId(file)}`;
|
||||
}
|
||||
|
||||
static viewerUrl(item) {
|
||||
const type = DocumentFormatter.fileInfoFromItem(item).type;
|
||||
const params = new URLSearchParams({
|
||||
id: this.fileId(item.file),
|
||||
name: item.nama_berkas,
|
||||
type,
|
||||
});
|
||||
return `viewer.html?${params.toString()}`;
|
||||
}
|
||||
|
||||
static async fetchPublished(collection) {
|
||||
const response = await fetch(this.itemsUrl(collection));
|
||||
if (!response.ok) throw new Error(`Gagal memuat koleksi ${collection}.`);
|
||||
const body = await response.json();
|
||||
const items = body.data || [];
|
||||
return items.filter(item => item.status === 'published');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export class FileDownloader {
|
||||
static async download(url, filename) {
|
||||
const downloadUrl = this.originalDownloadUrl(url);
|
||||
|
||||
try {
|
||||
const response = await fetch(downloadUrl);
|
||||
if (!response.ok) throw new Error('Gagal mengunduh berkas.');
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
this._click(blobUrl, filename);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch (err) {
|
||||
console.warn('Unduh via Blob gagal, beralih ke parameter Directus:', err);
|
||||
this._click(downloadUrl, filename);
|
||||
}
|
||||
}
|
||||
|
||||
static originalDownloadUrl(url) {
|
||||
if (!url) return '';
|
||||
|
||||
const hashIndex = url.indexOf('#');
|
||||
const hash = hashIndex >= 0 ? url.slice(hashIndex) : '';
|
||||
const base = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
|
||||
|
||||
if (/(^|[?&])download(?:[=&]|$)/.test(base)) return url;
|
||||
return `${base}${base.includes('?') ? '&' : '?'}download${hash}`;
|
||||
}
|
||||
|
||||
static _click(href, filename) {
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = href;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export function footerTemplate() {
|
||||
return ` <footer class="bg-untan-navy text-white pt-16 relative overflow-hidden mt-0">
|
||||
<div class="container mx-auto px-4 lg:px-8 relative z-10 flex flex-col items-center justify-center text-center">
|
||||
|
||||
<h3 class="text-xs font-bold text-slate-300 uppercase tracking-widest mb-4" data-i18n="footer_follow_us">
|
||||
Ikuti Kami</h3>
|
||||
<div class="flex items-center justify-center gap-5 sm:gap-6 mb-10">
|
||||
<a href="https://instagram.com/informatika.untan" target="_blank" rel="noopener noreferrer"
|
||||
class="text-white hover:text-untan-yellow transition-colors"><i
|
||||
class="fab fa-instagram text-xl sm:text-2xl"></i></a>
|
||||
<a href="https://instagram.com/informatika.untan" target="_blank" rel="noopener noreferrer"
|
||||
class="text-white hover:text-untan-yellow transition-colors"><i
|
||||
class="fab fa-youtube text-xl sm:text-2xl"></i></a>
|
||||
<a href="https://instagram.com/informatika.untan" target="_blank" rel="noopener noreferrer"
|
||||
class="text-white hover:text-untan-yellow transition-colors"><i
|
||||
class="fab fa-facebook-f text-xl sm:text-2xl"></i></a>
|
||||
<a href="https://instagram.com/informatika.untan" target="_blank" rel="noopener noreferrer"
|
||||
class="text-white hover:text-untan-yellow transition-colors"><i
|
||||
class="fab fa-twitter text-xl sm:text-2xl"></i></a>
|
||||
<a href="https://instagram.com/informatika.untan" target="_blank" rel="noopener noreferrer"
|
||||
class="text-white hover:text-untan-yellow transition-colors"><i
|
||||
class="fab fa-linkedin-in text-xl sm:text-2xl"></i></a>
|
||||
<a href="https://instagram.com/informatika.untan" target="_blank" rel="noopener noreferrer"
|
||||
class="text-white hover:text-untan-yellow transition-colors"><i
|
||||
class="fab fa-tiktok text-xl sm:text-2xl"></i></a>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-4 sm:gap-6 mb-8 text-left">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/id/0/03/Lambang_Universitas_Tanjungpura.png"
|
||||
class="h-16 sm:h-20 shrink-0" alt="Logo UNTAN">
|
||||
<div
|
||||
class="border-t sm:border-t-0 sm:border-l-2 border-white/30 pt-4 sm:pt-0 sm:pl-5 py-1 flex flex-col justify-center leading-tight">
|
||||
<h2 class="text-2xl sm:text-3xl font-bold font-sans tracking-widest uppercase mb-1">INFORMATIKA</h2>
|
||||
<h2 class="text-xs sm:text-sm font-normal text-slate-200 tracking-widest uppercase mt-0.5">
|
||||
UNIVERSITAS TANJUNGPURA</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-5 text-sm sm:text-base text-slate-100 max-w-2xl font-light">
|
||||
<p class="flex flex-col sm:flex-row items-center justify-center gap-2 mb-1">
|
||||
<i class="fas fa-map-marker-alt text-untan-yellow"></i>
|
||||
Jl. Prof. Dr. H. Hadari Nawawi, Bansir Laut, Kec. Pontianak Tenggara,
|
||||
</p>
|
||||
<p>Kota Pontianak, Kalimantan Barat 78124</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-xs sm:text-sm text-slate-200 flex flex-wrap justify-center items-center gap-x-3 gap-y-2 mb-12 font-light tracking-wide">
|
||||
<span>E: info@informatika.untan.ac.id</span>
|
||||
<span class="hidden sm:inline opacity-50">|</span>
|
||||
<span>P: +62 851-7165-5161</span>
|
||||
<span class="hidden sm:inline opacity-50">|</span>
|
||||
<span>F: +62(561) 739637</span>
|
||||
<span class="hidden sm:inline opacity-50">|</span>
|
||||
<span>WA: +62 851-7165-5161</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<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"
|
||||
style="object-position: bottom;">
|
||||
</div>
|
||||
</footer>`;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
export function mainContentTemplate() {
|
||||
return `<main id="main-content" class="tapops-doc-root pt-[72px] lg:pt-[160px]">
|
||||
|
||||
<!-- ═══════════════════════════════════════
|
||||
HERO
|
||||
═══════════════════════════════════════ -->
|
||||
<section class="research-hero immersive-hero">
|
||||
<div class="hero-container-centered">
|
||||
<span class="hero-kicker" data-tp-i18n="hero_kicker">Digital Administrative Hub</span>
|
||||
<h2 class="hero-title" data-tp-i18n="hero_title">Administrasi Akademik Digital</h2>
|
||||
<p class="hero-tagline" data-tp-i18n="hero_tagline">
|
||||
Satu pintu menuju semua dokumen penting. Akses terpusat menuju repositori dokumen akademik Program Studi
|
||||
Informatika Universitas Tanjungpura untuk mahasiswa dan staf.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a href="#repository" class="btn-join"><span data-tp-i18n="hero_btn_search">Cari Dokumen</span> <i class="fas fa-search"></i></a>
|
||||
<a href="#procedures" class="btn-outline"><span data-tp-i18n="hero_btn_flow">ALUR PENGAJUAN</span> <i class="fas fa-chevron-right"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-floating-stat-bar">
|
||||
<div class="stat-bar-item">
|
||||
<span class="stat-bar-value" id="stat-active-value">—</span>
|
||||
<span class="stat-bar-label" data-tp-i18n="stat_active">Dokumen Aktif</span>
|
||||
</div>
|
||||
<div class="stat-bar-divider"></div>
|
||||
<div class="stat-bar-item">
|
||||
<span class="stat-bar-value" id="stat-updates-value">—</span>
|
||||
<span class="stat-bar-label" data-tp-i18n="stat_updates">Pembaruan Terbaru</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════
|
||||
DOCUMENT REPOSITORY
|
||||
═══════════════════════════════════════ -->
|
||||
<section id="repository" class="documents-section">
|
||||
<div class="repo-dashboard-container">
|
||||
<!-- Sidebar Filter -->
|
||||
<aside class="repo-sidebar">
|
||||
<div class="sidebar-sticky">
|
||||
<h3 class="sidebar-title" data-tp-i18n="sidebar_category">KATEGORI BERKAS</h3>
|
||||
<div class="document-filter-wrapper" id="tag-filter-wrapper">
|
||||
<!-- Filter buttons are built dynamically from API tags by main.js -->
|
||||
<div class="filter-skeleton">
|
||||
<div class="filter-skeleton-item"></div>
|
||||
<div class="filter-skeleton-item short"></div>
|
||||
<div class="filter-skeleton-item"></div>
|
||||
<div class="filter-skeleton-item short"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-info-box">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<p data-tp-i18n="sidebar_info">Pastikan mengunduh versi terbaru untuk menjamin validitas berkas pengajuan.</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Document Area (div, not main — only one <main> per page) -->
|
||||
<div class="repo-main-content">
|
||||
<div class="repo-content-header">
|
||||
<div class="header-title-area">
|
||||
<h2 class="section-title" data-tp-i18n="repo_title">Eksplorasi Dokumen Prodi</h2>
|
||||
<p class="section-subtitle" data-tp-i18n="repo_subtitle">Temukan berkas administrasi dan akademik terbaru Program Studi
|
||||
Informatika.</p>
|
||||
</div>
|
||||
|
||||
<div class="repo-controls">
|
||||
<div class="search-container">
|
||||
<div class="search-input-group">
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<input type="text" id="doc-search" data-tp-i18n-placeholder="search_ph" placeholder="Cari nama berkas atau kata kunci...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-controls">
|
||||
<div class="control-group date-range-group" id="date-range-control-group">
|
||||
<span class="control-label" data-tp-i18n="label_period">Periode Unggahan</span>
|
||||
<button type="button" id="date-picker-trigger" class="date-picker-trigger">
|
||||
<div class="trigger-left-content">
|
||||
<i class="far fa-calendar-alt"></i>
|
||||
<span id="date-picker-label" data-tp-i18n="filter_all_time">Semua Waktu</span>
|
||||
</div>
|
||||
<i class="fas fa-chevron-down trigger-arrow"></i>
|
||||
</button>
|
||||
|
||||
<div id="date-picker-backdrop" class="date-picker-backdrop"></div>
|
||||
|
||||
<div id="date-picker-popover" class="date-picker-popover">
|
||||
<div class="date-picker-top-bar">
|
||||
<div class="date-display-inputs">
|
||||
<div class="date-display-field">
|
||||
<input type="text" id="picker-start-display" placeholder="DD/MM/YYYY">
|
||||
</div>
|
||||
<span class="date-picker-separator">-</span>
|
||||
<div class="date-display-field">
|
||||
<input type="text" id="picker-end-display" placeholder="DD/MM/YYYY">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="date-picker-body">
|
||||
<div class="calendar-container">
|
||||
<div class="calendar-nav">
|
||||
<button type="button" id="prev-month-btn" class="nav-btn"><i class="fas fa-chevron-left"></i></button>
|
||||
<div class="month-labels">
|
||||
<div id="left-month-label" class="month-label">
|
||||
<span id="left-month-name">Mei</span>
|
||||
<div class="year-select-wrapper">
|
||||
<select id="left-year-select" class="calendar-year-select"></select>
|
||||
<i class="fas fa-chevron-down select-arrow"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div id="right-month-label" class="month-label">
|
||||
<span id="right-month-name">Juni</span>
|
||||
<div class="year-select-wrapper">
|
||||
<select id="right-year-select" class="calendar-year-select"></select>
|
||||
<i class="fas fa-chevron-down select-arrow"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" id="next-month-btn" class="nav-btn"><i class="fas fa-chevron-right"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="calendar-grids">
|
||||
<div class="calendar-grid-pane" id="left-calendar-pane">
|
||||
<div class="day-headers"></div>
|
||||
<div class="days-container" id="left-days-container"></div>
|
||||
</div>
|
||||
<div class="calendar-grid-pane" id="right-calendar-pane">
|
||||
<div class="day-headers"></div>
|
||||
<div class="days-container" id="right-days-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="presets-container">
|
||||
<span class="presets-title" data-tp-i18n="presets_title">RENTANG WAKTU</span>
|
||||
<div class="presets-list">
|
||||
<button type="button" class="preset-btn" data-preset="all" data-tp-i18n="filter_all_time">Semua Waktu</button>
|
||||
<button type="button" class="preset-btn" data-preset="today" data-tp-i18n="preset_today">Hari Ini</button>
|
||||
<button type="button" class="preset-btn" data-preset="yesterday" data-tp-i18n="preset_yesterday">Kemarin</button>
|
||||
<button type="button" class="preset-btn" data-preset="last7" data-tp-i18n="preset_last_7_days">7 Hari Terakhir</button>
|
||||
<button type="button" class="preset-btn" data-preset="last14" data-tp-i18n="preset_last_14_days">14 Hari Terakhir</button>
|
||||
<button type="button" class="preset-btn" data-preset="last30" data-tp-i18n="preset_last_30_days">30 Hari Terakhir</button>
|
||||
<button type="button" class="preset-btn" data-preset="thisWeek" data-tp-i18n="preset_this_week">Minggu Ini</button>
|
||||
<button type="button" class="preset-btn" data-preset="lastWeek" data-tp-i18n="preset_last_week">Minggu Lalu</button>
|
||||
<button type="button" class="preset-btn" data-preset="thisMonth" data-tp-i18n="preset_this_month">Bulan Ini</button>
|
||||
<button type="button" class="preset-btn" data-preset="lastMonth" data-tp-i18n="preset_last_month">Bulan Lalu</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="date-picker-footer">
|
||||
<button type="button" id="picker-cancel-btn" class="action-btn cancel-btn" data-tp-i18n="btn_cancel">Batal</button>
|
||||
<button type="button" id="picker-apply-btn" class="action-btn apply-btn" data-tp-i18n="btn_update">Perbarui</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<span class="control-label" data-tp-i18n="label_sort">Urutkan</span>
|
||||
<div id="sort-filter-wrapper"></div>
|
||||
</div>
|
||||
|
||||
<div class="control-group mobile-category-control-group">
|
||||
<span class="control-label" data-tp-i18n="sidebar_category">Kategori Berkas</span>
|
||||
<div id="mobile-tag-filter-wrapper"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-grid" id="document-grid">
|
||||
<div class="repo-loading" style="grid-column: 1 / -1; text-align: center; padding: 60px 20px; color: var(--primary-blue); font-weight: 700; font-size: 1.1rem; display: flex; flex-direction: column; align-items: center; gap: 15px;">
|
||||
<i class="fas fa-circle-notch fa-spin" style="font-size: 2.5rem; color: var(--secondary-gold);"></i>
|
||||
<span data-tp-i18n="loading">Menghubungkan ke repositori berkas...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════
|
||||
PROCEDURES
|
||||
═══════════════════════════════════════ -->
|
||||
<section id="procedures" class="journey-section">
|
||||
<div class="journey-container">
|
||||
<div class="section-header centered">
|
||||
<h2 class="section-title" data-tp-i18n="proc_title">Tata Cara Pengajuan Berkas</h2>
|
||||
<span class="section-tag" data-tp-i18n="proc_tag">Alur Pengajuan</span>
|
||||
</div>
|
||||
|
||||
<div class="process-flow-wrapper">
|
||||
<!-- Step 01 -->
|
||||
<div class="process-step">
|
||||
<div class="step-visual">
|
||||
<div class="step-icon-box"><i class="fas fa-file-download"></i></div>
|
||||
<div class="step-number-badge">01</div>
|
||||
<div class="step-connector"></div>
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<h3 data-tp-i18n="proc_s1_h">Unduh Formulir</h3>
|
||||
<p data-tp-i18n="proc_s1_p">Pilih kategori pengajuan Anda, lalu unduh formulir versi terbaru. Pastikan kode revisi pada formulir sesuai dengan yang tertera di halaman ini.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 02 -->
|
||||
<div class="process-step">
|
||||
<div class="step-visual">
|
||||
<div class="step-icon-box"><i class="fas fa-edit"></i></div>
|
||||
<div class="step-number-badge">02</div>
|
||||
<div class="step-connector"></div>
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<h3 data-tp-i18n="proc_s2_h">Lengkapi Formulir</h3>
|
||||
<p data-tp-i18n="proc_s2_p">Isi setiap kolom dengan lengkap dan teliti. Sertakan seluruh dokumen pendukung yang diminta sebelum pengiriman.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 03 -->
|
||||
<div class="process-step">
|
||||
<div class="step-visual">
|
||||
<div class="step-icon-box"><i class="fas fa-paper-plane"></i></div>
|
||||
<div class="step-number-badge">03</div>
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<h3 data-tp-i18n="proc_s3_h">Kirim Berkas</h3>
|
||||
<p data-tp-i18n="proc_s3_p">Kirimkan formulir beserta dokumen pendukung melalui email resmi atau WhatsApp admin prodi. Berkas yang lengkap akan langsung diproses oleh admin prodi.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════
|
||||
HELP CENTER
|
||||
═══════════════════════════════════════ -->
|
||||
<section id="help" class="help-center-section">
|
||||
<div class="help-container">
|
||||
<div class="help-header-centered">
|
||||
<h2 class="section-title" data-tp-i18n="help_title">Butuh Bantuan Lebih Lanjut?</h2>
|
||||
<p data-tp-i18n="help_desc">Tim admin kami siap membantu Anda melalui kanal komunikasi resmi di bawah ini.</p>
|
||||
</div>
|
||||
|
||||
<div class="help-cards-grid">
|
||||
<div class="interaction-card help-official">
|
||||
<div class="card-inner">
|
||||
<div class="card-icon"><i class="fas fa-envelope-open-text"></i></div>
|
||||
<h3 data-tp-i18n="help_email_title">Korespondensi Resmi (Email)</h3>
|
||||
<p data-tp-i18n="help_email_desc">Gunakan untuk permohonan form khusus non-publik atau administrasi formal lainnya.</p>
|
||||
<ul class="card-checklist">
|
||||
<li><i class="fas fa-check"></i> <span data-tp-i18n="help_email_c1">Respon Maks. 24 Jam</span></li>
|
||||
<li><i class="fas fa-check"></i> <span data-tp-i18n="help_email_c2">Pelayanan Formal</span></li>
|
||||
</ul>
|
||||
<a href="https://mail.google.com/mail/?view=cm&fs=1&to=admin@informatika.untan.ac.id&su=Layanan%20Administrasi%20%26%20CS%20Informatika%20UNTAN&body=Halo%20Admin%20Prodi%20Informatika%20UNTAN%2C%0A%0ASaya%20ingin%20mengajukan%20permohonan%20%2F%20melaporkan%20kendala%20terkait%20administrasi%20akademik.%20Berikut%20detail%20informasi%20saya%3A%0A%0ANama%3A%20%5BNama%20Lengkap%20Anda%5D%0ANIM%3A%20%5BNIM%20Anda%5D%0AKategori%20Layanan%3A%20%5BTata%20Cara%20Pengajuan%20%2F%20Kendala%20Teknis%20%26%20CS%5D%0A%0ADetail%20Masalah%20%2F%20Pengajuan%3A%0A%5BTuliskan%20pertanyaan%2C%20detail%20pengajuan%2C%20atau%20kendala%20Anda%20di%20sini...%5D%0A%0ATerima%20kasih." target="_blank" rel="noopener noreferrer" class="btn-interaction"><span data-tp-i18n="help_email_btn">HUBUNGI ADMIN</span> <i class="fas fa-paper-plane"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="interaction-card help-urgent">
|
||||
<div class="card-inner">
|
||||
<div class="card-icon"><i class="fab fa-whatsapp"></i></div>
|
||||
<h3 data-tp-i18n="help_wa_title">Quick Support (WhatsApp)</h3>
|
||||
<p data-tp-i18n="help_wa_desc">Konsultasi cepat alur birokrasi atau pelaporan kendala akses pada berkas repositori.</p>
|
||||
<ul class="card-checklist">
|
||||
<li><i class="fas fa-check"></i> <span data-tp-i18n="help_wa_c1">Respon Instan Jam Kerja</span></li>
|
||||
<li><i class="fas fa-check"></i> <span data-tp-i18n="help_wa_c2">Konsultasi Teknis</span></li>
|
||||
</ul>
|
||||
<a href="https://wa.me/6285171655161?text=Halo%20Admin%20Prodi%20Informatika%20UNTAN%2C%0A%0ASaya%20ingin%20mengajukan%20permohonan%20%2F%20melaporkan%20kendala%20terkait%20administrasi%20akademik.%20Berikut%20detail%20informasi%20saya%3A%0A%0ANama%3A%20%5BNama%20Lengkap%20Anda%5D%0ANIM%3A%20%5BNIM%20Anda%5D%0AKategori%20Layanan%3A%20%5BTata%20Cara%20Pengajuan%20%2F%20Kendala%20Teknis%20%26%20CS%5D%0A%0ADetail%20Masalah%20%2F%20Pengajuan%3A%0A%5BTuliskan%20pertanyaan%2C%20detail%20pengajuan%2C%20atau%20kendala%20Anda%20di%20sini...%5D%0A%0ATerima%20kasih." target="_blank" rel="noopener noreferrer" class="btn-interaction-alt"><span data-tp-i18n="help_wa_btn">CHAT WHATSAPP</span> <i class="fab fa-whatsapp"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>`;
|
||||
}
|
||||
@@ -0,0 +1,843 @@
|
||||
export function navigationTemplate() {
|
||||
return ` <a href="#main-content" class="skip-link" data-i18n="skip_to_content">Langsung ke Konten Utama</a>
|
||||
|
||||
<div id="nav-wrapper"
|
||||
class="fixed top-0 left-0 right-0 z-50 transition-shadow duration-300 shadow-sm border-b border-slate-200 will-change-auto">
|
||||
<div class="bg-untan-navy text-white py-3 lg:py-4 relative overflow-hidden" style="min-height:0;">
|
||||
<div class="absolute inset-0 w-full overflow-hidden pointer-events-none" aria-hidden="true">
|
||||
<div class="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;max-height:100%;">
|
||||
01101001 01101110 01100110 01101111 01110010 01101101 01100001 01110100 01101001
|
||||
01101011 01100001 00100000 01110101 01101110 01110100 01100001 01101110 00100000
|
||||
01100010 01101001 01110011 01100001 01101001 01101110 01100110 01101111 01110010
|
||||
01101101 01100001 01110100 01101001 01101011 01100001 00100000 01110101 01101110
|
||||
01110100 01100001 01101110 00100000 01100010 01101001 01110011 01100001 01010101
|
||||
01001110 01010100 01000001 01001110 00100000 00110010 00110000 00110010 00110101
|
||||
00100000 01100110 01110101 01110100 01110101 01110010 01100101 00100000 01110010
|
||||
01100101 01100001 01100100 01111001 01101001 01101110 01100110 01101111 01110010
|
||||
01101101 01100001 01110100 01101001 01101011 01100001 00100000 01110101 01101110
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-untan-navy via-untan-navy/80 to-transparent"></div>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-4 lg:px-6 flex justify-between items-center relative z-10">
|
||||
<a href="../../index.html" class="flex items-center gap-4 group">
|
||||
<div class="relative w-12 h-12 lg:w-14 lg:h-14 flex items-center justify-center shrink-0">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/id/0/03/Lambang_Universitas_Tanjungpura.png"
|
||||
alt="Logo Universitas Tanjungpura - Lambang resmi UNTAN"
|
||||
class="w-full h-full object-contain transition-transform duration-500 group-hover:scale-105 group-hover:rotate-3 relative z-10">
|
||||
</div>
|
||||
<div class="border-l-2 border-white/30 pl-4 lg:pl-5 py-1 flex flex-col justify-center">
|
||||
<span
|
||||
class="text-lg lg:text-2xl font-bold tracking-widest text-white leading-none font-sans drop-shadow-sm mb-1 uppercase">INFORMATIKA</span>
|
||||
<span
|
||||
class="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 class="hidden lg:flex items-center gap-5">
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<a href="https://satu.untan.ac.id" target="_blank"
|
||||
class="hover:text-untan-yellow transition-colors font-medium">SATU UNTAN</a>
|
||||
</div>
|
||||
<div class="pl-5 border-l border-white/20 flex items-center gap-3">
|
||||
<button
|
||||
class="language-toggle-btn text-xs hover:text-untan-yellow transition-colors flex items-center gap-1">
|
||||
<span class="font-bold">ID</span> <span class="text-white/40">|</span> EN
|
||||
</button>
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center rounded-sm hover:bg-white/10 transition-colors"
|
||||
aria-label="Pengaturan aksesibilitas" data-accessibility-toggle>
|
||||
<i class="fas fa-universal-access" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center rounded-sm hover:bg-white/10 transition-colors"
|
||||
aria-label="Cari konten">
|
||||
<i class="fas fa-search" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="mobile-menu-button"
|
||||
class="lg:hidden text-xl text-white w-9 h-9 flex items-center justify-center rounded-sm hover:bg-white/10 transition-colors"
|
||||
aria-label="Buka menu navigasi" aria-expanded="false" aria-controls="mobile-menu">
|
||||
<i class="fas fa-bars" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="bg-[#feb401] shadow-md hidden lg:block border-t border-white/20 relative" id="main-nav"
|
||||
aria-label="Menu navigasi utama">
|
||||
<div class="container mx-auto px-6">
|
||||
<div class="flex items-center justify-center w-full gap-1 py-2" role="menubar">
|
||||
|
||||
<!-- Menu: Tentang Kami -->
|
||||
<div class="nav-menu-item relative" data-menu="tentang">
|
||||
<button
|
||||
class="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 data-i18n="nav_about">Tentang Kami</span>
|
||||
<i class="fas fa-chevron-down text-[10px] opacity-70 transition-transform duration-300"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Menu: Program -->
|
||||
<div class="nav-menu-item relative" data-menu="program">
|
||||
<button
|
||||
class="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 data-i18n="nav_program">Program</span>
|
||||
<i class="fas fa-chevron-down text-[10px] opacity-70 transition-transform duration-300"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Menu: SDM -->
|
||||
<div class="nav-menu-item relative" data-menu="sdm">
|
||||
<button
|
||||
class="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 data-i18n="nav_sdm">SDM</span>
|
||||
<i class="fas fa-chevron-down text-[10px] opacity-70 transition-transform duration-300"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Menu: Fasilitas -->
|
||||
<div class="nav-menu-item relative" data-menu="fasilitas">
|
||||
<button
|
||||
class="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 data-i18n="nav_facilities">Fasilitas</span>
|
||||
<i class="fas fa-chevron-down text-[10px] opacity-70 transition-transform duration-300"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Menu: Karya & Agenda -->
|
||||
<div class="nav-menu-item relative" data-menu="karya">
|
||||
<button
|
||||
class="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 data-i18n="nav_works">Karya & Agenda</span>
|
||||
<i class="fas fa-chevron-down text-[10px] opacity-70 transition-transform duration-300"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Menu: Kontak -->
|
||||
<div class="nav-menu-item relative" data-menu="kontak">
|
||||
<button
|
||||
class="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 data-i18n="nav_contact">Kontak</span>
|
||||
<i class="fas fa-chevron-down text-[10px] opacity-70 transition-transform duration-300"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MEGA MENU PANELS (Inside nav for proper positioning) -->
|
||||
<div id="mega-menu-container" class="absolute left-0 right-0 top-full z-50">
|
||||
|
||||
<!-- Mega Menu: Tentang Kami -->
|
||||
<div id="mega-tentang" class="mega-panel bg-white border-b-4 border-untan-navy shadow-2xl">
|
||||
<div class="container mx-auto px-6 py-8">
|
||||
<div class="grid grid-cols-12 gap-8">
|
||||
<!-- Left: Featured -->
|
||||
<div
|
||||
class="col-span-4 bg-gradient-to-br from-untan-navy to-slate-800 rounded-2xl p-6 text-white relative overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 right-0 w-32 h-32 bg-untan-yellow/10 rounded-full -mr-16 -mt-16">
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 w-24 h-24 bg-white/5 rounded-full -ml-12 -mb-12">
|
||||
</div>
|
||||
<div class="relative z-10">
|
||||
<span
|
||||
class="inline-block px-3 py-1 bg-untan-yellow text-slate-900 text-[10px] font-bold uppercase tracking-wider rounded-full mb-4"
|
||||
data-i18n="mega_about_title">Tentang Kami</span>
|
||||
<h3 class="text-2xl font-bold mb-3">Mengenal Informatika UNTAN</h3>
|
||||
<p class="text-slate-300 text-sm mb-6 leading-relaxed" data-i18n="mega_about_desc">
|
||||
Terakreditasi Unggul, mencetak lulusan kompeten di bidang teknologi informasi
|
||||
sejak 1993.</p>
|
||||
<a href="content.html?type=berita"
|
||||
class="inline-flex items-center gap-2 text-untan-yellow font-semibold text-sm hover:gap-3 transition-all">
|
||||
Selengkapnya <i class="fas fa-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: Links -->
|
||||
<div class="col-span-8 grid grid-cols-2 gap-6">
|
||||
<a href="content.html?type=berita" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-amber-50 text-amber-600 group-hover:bg-amber-600 group-hover:text-white">
|
||||
<i class="fas fa-landmark"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue transition-colors"
|
||||
data-i18n="link_history">Sejarah & Tonggak</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_history_desc">Perjalanan
|
||||
panjang membangun generasi digital</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="content.html?type=berita" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-blue-50 text-blue-600 group-hover:bg-blue-600 group-hover:text-white">
|
||||
<i class="fas fa-users"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue transition-colors"
|
||||
data-i18n="link_org">Struktur Organisasi</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_org_desc">Pimpinan dan
|
||||
jajaran pengelola</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="profil.html" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-emerald-50 text-emerald-600 group-hover:bg-emerald-600 group-hover:text-white">
|
||||
<i class="fas fa-bullseye"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue transition-colors"
|
||||
data-i18n="link_vision">Visi & Misi</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_vision_desc">Arah dan
|
||||
tujuan pengembangan prodi</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="content.html?type=berita" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-purple-50 text-purple-600 group-hover:bg-purple-600 group-hover:text-white">
|
||||
<i class="fas fa-handshake"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue transition-colors"
|
||||
data-i18n="link_partnership">Kerjasama & MoU</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_partnership_desc">Mitra
|
||||
industri dan akademik</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mega Menu: Program -->
|
||||
<div id="mega-program" class="mega-panel bg-white border-b-4 border-untan-navy shadow-2xl">
|
||||
<div class="container mx-auto px-6 py-8">
|
||||
<div class="grid grid-cols-12 gap-8">
|
||||
<!-- Left: Featured -->
|
||||
<div
|
||||
class="col-span-4 bg-gradient-to-br from-blue-600 to-indigo-700 rounded-2xl p-6 text-white relative overflow-hidden">
|
||||
<div class="absolute top-0 right-0 w-40 h-40 bg-white/10 rounded-full -mr-20 -mt-20">
|
||||
</div>
|
||||
<div class="relative z-10">
|
||||
<span
|
||||
class="inline-block px-3 py-1 bg-white/20 text-white text-[10px] font-bold uppercase tracking-wider rounded-full mb-4">Program
|
||||
Akademik</span>
|
||||
<h3 class="text-2xl font-bold mb-3" data-i18n="mega_program_obe">Kurikulum Berbasis
|
||||
OBE</h3>
|
||||
<p class="text-blue-100 text-sm mb-6 leading-relaxed">Outcome-Based Education dengan
|
||||
4 jalur konsentrasi: AI, Software Engineering, Network, dan Information Systems.
|
||||
</p>
|
||||
<a href="subject-and-courses.html"
|
||||
class="inline-flex items-center gap-2 bg-white text-blue-600 px-4 py-2 rounded-lg font-semibold text-sm hover:bg-blue-50 transition-all"
|
||||
data-i18n="btn_krs_sim">
|
||||
<i class="fas fa-play-circle"></i> Simulasi KRS
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: Links Grid -->
|
||||
<div class="col-span-8">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<a href="subject-and-courses.html" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-indigo-50 text-indigo-600 group-hover:bg-indigo-600 group-hover:text-white">
|
||||
<i class="fas fa-graduation-cap"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue"
|
||||
data-i18n="link_academic">Akademik</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_academic_desc">
|
||||
Jadwal, KRS, dan kalender</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="content.html?type=berita" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-yellow-50 text-yellow-600 group-hover:bg-yellow-600 group-hover:text-white">
|
||||
<i class="fas fa-award"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue"
|
||||
data-i18n="link_accreditation">Akreditasi</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_accred_desc">Status
|
||||
Unggul BAN-PT</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="graduate-profile.html" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-green-50 text-green-600 group-hover:bg-green-600 group-hover:text-white">
|
||||
<i class="fas fa-user-graduate"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue"
|
||||
data-i18n="link_graduate">Profil Lulusan</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_graduate_desc">
|
||||
Kompetensi & karir</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="subject-and-courses.html" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-rose-50 text-rose-600 group-hover:bg-rose-600 group-hover:text-white">
|
||||
<i class="fas fa-book-open"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue"
|
||||
data-i18n="link_courses">Mata Kuliah</h4>
|
||||
<p class="text-xs text-slate-500 mt-1">144 SKS kurikulum</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="academic-guidelines.html" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-cyan-50 text-cyan-600 group-hover:bg-cyan-600 group-hover:text-white">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue"
|
||||
data-i18n="link_guideline">Panduan</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_guideline_desc">Buku
|
||||
panduan akademik</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="content.html?type=berita" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-orange-50 text-orange-600 group-hover:bg-orange-600 group-hover:text-white">
|
||||
<i class="fas fa-chart-bar"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue"
|
||||
data-i18n="link_statistics">Informatika Angka</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_statistics_desc">
|
||||
Statistik & data</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mega Menu: SDM -->
|
||||
<div id="mega-sdm" class="mega-panel bg-white border-b-4 border-untan-navy shadow-2xl">
|
||||
<div class="container mx-auto px-6 py-8">
|
||||
<div class="grid grid-cols-12 gap-8">
|
||||
<!-- Left: Featured Dosen -->
|
||||
<div
|
||||
class="col-span-5 bg-gradient-to-br from-slate-800 to-slate-900 rounded-2xl p-6 text-white relative overflow-hidden">
|
||||
<div class="absolute inset-0 opacity-10">
|
||||
<div class="absolute top-4 right-4 w-20 h-20 border-2 border-white rounded-full">
|
||||
</div>
|
||||
<div class="absolute bottom-4 left-4 w-16 h-16 border-2 border-white rounded-full">
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative z-10">
|
||||
<span
|
||||
class="inline-block px-3 py-1 bg-untan-yellow text-slate-900 text-[10px] font-bold uppercase tracking-wider rounded-full mb-4"
|
||||
data-i18n="mega_sdm_title">Sumber Daya Manusia</span>
|
||||
<h3 class="text-2xl font-bold mb-3" data-i18n="mega_sdm_desc">Tim Pengajar
|
||||
Profesional</h3>
|
||||
<p class="text-slate-300 text-sm mb-4 leading-relaxed">30+ dosen berkualifikasi
|
||||
S2/S3 dari universitas terkemuka dalam dan luar negeri.</p>
|
||||
<div class="flex gap-4 mb-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-untan-yellow">15+</div>
|
||||
<div class="text-[10px] text-slate-400 uppercase">Doktor</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-untan-yellow">20+</div>
|
||||
<div class="text-[10px] text-slate-400 uppercase">Magister</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-untan-yellow">5</div>
|
||||
<div class="text-[10px] text-slate-400 uppercase">Guru Besar</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="people-based-on-kk.html"
|
||||
class="inline-flex items-center gap-2 text-untan-yellow font-semibold text-sm hover:gap-3 transition-all"
|
||||
data-i18n="btn_all_lecturers">
|
||||
Lihat Semua Dosen <i class="fas fa-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: KK Groups -->
|
||||
<div class="col-span-7">
|
||||
<h4 class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4"
|
||||
data-i18n="label_expertise">Kelompok Keahlian</h4>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<a href="people-based-on-kk.html"
|
||||
class="group bg-gradient-to-r from-purple-50 to-white border border-purple-100 rounded-xl p-5 hover:shadow-lg hover:border-purple-300 transition-all">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="w-12 h-12 bg-purple-600 rounded-xl flex items-center justify-center text-white text-xl group-hover:scale-110 transition-transform">
|
||||
<i class="fas fa-brain"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-bold text-slate-800 mb-1">KK Kecerdasan Buatan</h5>
|
||||
<p class="text-xs text-slate-500">Machine Learning, Computer Vision, NLP
|
||||
</p>
|
||||
<span class="inline-block mt-2 text-xs text-purple-600 font-semibold">8
|
||||
Dosen <i class="fas fa-chevron-right ml-1"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="people-based-on-kk.html"
|
||||
class="group bg-gradient-to-r from-blue-50 to-white border border-blue-100 rounded-xl p-5 hover:shadow-lg hover:border-blue-300 transition-all">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="w-12 h-12 bg-blue-600 rounded-xl flex items-center justify-center text-white text-xl group-hover:scale-110 transition-transform">
|
||||
<i class="fas fa-code"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-bold text-slate-800 mb-1">KK Rekayasa Perangkat Lunak
|
||||
</h5>
|
||||
<p class="text-xs text-slate-500">Software Engineering, DevOps, Testing
|
||||
</p>
|
||||
<span class="inline-block mt-2 text-xs text-blue-600 font-semibold">10
|
||||
Dosen <i class="fas fa-chevron-right ml-1"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="people-based-on-kk.html"
|
||||
class="group bg-gradient-to-r from-emerald-50 to-white border border-emerald-100 rounded-xl p-5 hover:shadow-lg hover:border-emerald-300 transition-all">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="w-12 h-12 bg-emerald-600 rounded-xl flex items-center justify-center text-white text-xl group-hover:scale-110 transition-transform">
|
||||
<i class="fas fa-network-wired"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-bold text-slate-800 mb-1">KK Jaringan & Keamanan</h5>
|
||||
<p class="text-xs text-slate-500">Network, Cybersecurity, Cloud</p>
|
||||
<span class="inline-block mt-2 text-xs text-emerald-600 font-semibold">7
|
||||
Dosen <i class="fas fa-chevron-right ml-1"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="people-based-on-kk.html"
|
||||
class="group bg-gradient-to-r from-orange-50 to-white border border-orange-100 rounded-xl p-5 hover:shadow-lg hover:border-orange-300 transition-all">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="w-12 h-12 bg-orange-600 rounded-xl flex items-center justify-center text-white text-xl group-hover:scale-110 transition-transform">
|
||||
<i class="fas fa-database"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-bold text-slate-800 mb-1">KK Sistem Informasi</h5>
|
||||
<p class="text-xs text-slate-500">Data Science, GIS, Business Intel</p>
|
||||
<span class="inline-block mt-2 text-xs text-orange-600 font-semibold">6
|
||||
Dosen <i class="fas fa-chevron-right ml-1"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mega Menu: Fasilitas -->
|
||||
<div id="mega-fasilitas" class="mega-panel bg-white border-b-4 border-untan-navy shadow-2xl">
|
||||
<div class="container mx-auto px-6 py-8">
|
||||
<div class="grid grid-cols-4 gap-6">
|
||||
<a href="content.html?type=rubrik"
|
||||
class="group relative bg-gradient-to-br from-cyan-500 to-blue-600 rounded-2xl p-6 text-white overflow-hidden hover:shadow-xl transition-all">
|
||||
<div class="absolute top-0 right-0 w-24 h-24 bg-white/10 rounded-full -mr-12 -mt-12">
|
||||
</div>
|
||||
<i class="fas fa-flask text-4xl mb-4 opacity-80"></i>
|
||||
<h4 class="text-lg font-bold mb-2" data-i18n="mega_lab">Laboratorium</h4>
|
||||
<p class="text-sm text-cyan-100 mb-4" data-i18n="mega_lab_desc">8 lab modern dengan
|
||||
peralatan terkini</p>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-xs font-semibold opacity-80 group-hover:opacity-100 group-hover:gap-2 transition-all"
|
||||
data-i18n="btn_explore">
|
||||
Jelajahi <i class="fas fa-arrow-right"></i>
|
||||
</span>
|
||||
</a>
|
||||
<a href="content.html?type=rubrik"
|
||||
class="group relative bg-gradient-to-br from-violet-500 to-purple-600 rounded-2xl p-6 text-white overflow-hidden hover:shadow-xl transition-all">
|
||||
<div class="absolute top-0 right-0 w-24 h-24 bg-white/10 rounded-full -mr-12 -mt-12">
|
||||
</div>
|
||||
<i class="fas fa-server text-4xl mb-4 opacity-80"></i>
|
||||
<h4 class="text-lg font-bold mb-2" data-i18n="mega_dc">Data Center</h4>
|
||||
<p class="text-sm text-violet-100 mb-4" data-i18n="mega_dc_desc">Server & infrastruktur
|
||||
cloud lokal</p>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-xs font-semibold opacity-80 group-hover:opacity-100 group-hover:gap-2 transition-all"
|
||||
data-i18n="btn_explore">
|
||||
Jelajahi <i class="fas fa-arrow-right"></i>
|
||||
</span>
|
||||
</a>
|
||||
<a href="content.html?type=rubrik"
|
||||
class="group relative bg-gradient-to-br from-emerald-500 to-teal-600 rounded-2xl p-6 text-white overflow-hidden hover:shadow-xl transition-all">
|
||||
<div class="absolute top-0 right-0 w-24 h-24 bg-white/10 rounded-full -mr-12 -mt-12">
|
||||
</div>
|
||||
<i class="fas fa-book-reader text-4xl mb-4 opacity-80"></i>
|
||||
<h4 class="text-lg font-bold mb-2" data-i18n="mega_eresources">E-Resources</h4>
|
||||
<p class="text-sm text-emerald-100 mb-4" data-i18n="mega_eresources_desc">Akses jurnal &
|
||||
database ilmiah</p>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-xs font-semibold opacity-80 group-hover:opacity-100 group-hover:gap-2 transition-all"
|
||||
data-i18n="btn_explore">
|
||||
Jelajahi <i class="fas fa-arrow-right"></i>
|
||||
</span>
|
||||
</a>
|
||||
<a href="ifuntanhub.html"
|
||||
class="group relative bg-gradient-to-br from-slate-700 to-slate-900 rounded-2xl p-6 text-white overflow-hidden hover:shadow-xl transition-all">
|
||||
<div
|
||||
class="absolute top-0 right-0 w-24 h-24 bg-untan-yellow/20 rounded-full -mr-12 -mt-12">
|
||||
</div>
|
||||
<i class="fas fa-cloud text-4xl mb-4 opacity-80"></i>
|
||||
<h4 class="text-lg font-bold mb-2">IFUNTANHUB</h4>
|
||||
<p class="text-sm text-slate-300 mb-4" data-i18n="mega_hub_desc">Platform PaaS untuk
|
||||
mahasiswa</p>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-xs font-semibold text-untan-yellow group-hover:gap-2 transition-all">
|
||||
Launch <i class="fas fa-external-link-alt"></i>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mega Menu: Karya & Agenda -->
|
||||
<div id="mega-karya" class="mega-panel bg-white border-b-4 border-untan-navy shadow-2xl">
|
||||
<div class="container mx-auto px-6 py-8">
|
||||
<div class="grid grid-cols-12 gap-8">
|
||||
<!-- Left: Research Highlight -->
|
||||
<div class="col-span-5">
|
||||
<div
|
||||
class="bg-gradient-to-br from-amber-400 to-orange-500 rounded-2xl p-6 text-white mb-4">
|
||||
<span
|
||||
class="inline-block px-3 py-1 bg-white/20 text-white text-[10px] font-bold uppercase tracking-wider rounded-full mb-3"
|
||||
data-i18n="mega_research_title">Riset Unggulan</span>
|
||||
<h3 class="text-xl font-bold mb-2" data-i18n="mega_sinta_title">Indeks SINTA Terbaik
|
||||
</h3>
|
||||
<p class="text-amber-100 text-sm mb-4" data-i18n="mega_sinta_desc">Publikasi dosen
|
||||
terindeks Scopus, WoS, dan jurnal nasional terakreditasi.</p>
|
||||
<a href="content.html?type=berita"
|
||||
class="inline-flex items-center gap-2 bg-white text-orange-600 px-4 py-2 rounded-lg font-semibold text-sm hover:bg-orange-50 transition-all"
|
||||
data-i18n="btn_publications">
|
||||
Lihat Publikasi
|
||||
</a>
|
||||
</div>
|
||||
<a href="content.html?type=berita"
|
||||
class="block bg-slate-50 border border-slate-200 rounded-xl p-4 hover:bg-slate-100 hover:border-slate-300 transition-all">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center text-red-600">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-bold text-slate-800 text-sm" data-i18n="link_info_agenda">
|
||||
Info & Agenda</h5>
|
||||
<p class="text-xs text-slate-500" data-i18n="link_info_agenda_desc">
|
||||
Pengumuman dan jadwal kegiatan</p>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right text-slate-400 ml-auto"></i>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<!-- Right: Grid -->
|
||||
<div class="col-span-7 grid grid-cols-2 gap-4">
|
||||
<a href="karya.html" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-indigo-50 text-indigo-600 group-hover:bg-indigo-600 group-hover:text-white">
|
||||
<i class="fas fa-layer-group"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue">Semua Karya
|
||||
</h4>
|
||||
<p class="text-xs text-slate-500 mt-1">Lihat seluruh karya dalam satu halaman
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="karya.html?filter=penelitian" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-blue-50 text-blue-600 group-hover:bg-blue-600 group-hover:text-white">
|
||||
<i class="fas fa-microscope"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue"
|
||||
data-i18n="link_research">Riset</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_research_desc">Penelitian
|
||||
dosen dan mahasiswa</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="karya.html?filter=pkm" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-green-50 text-green-600 group-hover:bg-green-600 group-hover:text-white">
|
||||
<i class="fas fa-hands-helping"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue"
|
||||
data-i18n="link_community">Pengabdian</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_community_desc">Program
|
||||
pengabdian masyarakat</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="content.html?type=rubrik" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-amber-50 text-amber-600 group-hover:bg-amber-600 group-hover:text-white">
|
||||
<i class="fas fa-trophy"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue"
|
||||
data-i18n="link_achievements">Prestasi</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_achievements_desc">
|
||||
Pencapaian dosen & mahasiswa</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mega Menu: Kontak -->
|
||||
<div id="mega-kontak" class="mega-panel bg-white border-b-4 border-untan-navy shadow-2xl">
|
||||
<div class="container mx-auto px-6 py-8">
|
||||
<div class="grid grid-cols-12 gap-8">
|
||||
<!-- Left: Contact Info -->
|
||||
<div class="col-span-5 bg-untan-navy rounded-2xl p-6 text-white">
|
||||
<h3 class="text-xl font-bold mb-4" data-i18n="mega_contact_title">Hubungi Kami</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="w-10 h-10 bg-white/10 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-map-marker-alt text-untan-yellow"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-slate-300">Jl. Prof. Dr. H. Hadari Nawawi, Bansir
|
||||
Laut, Pontianak Tenggara</p>
|
||||
<p class="text-xs text-slate-400 mt-1">Kalimantan Barat 78124</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 bg-white/10 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fab fa-whatsapp text-untan-yellow"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-white font-medium">+62 851-7165-5161</p>
|
||||
<p class="text-xs text-slate-400">Senin - Jumat, 08:00 - 16:00</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 bg-white/10 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-envelope text-untan-yellow"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-white font-medium">info@informatika.untan.ac.id</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: Quick Links & Social -->
|
||||
<div class="col-span-7">
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<a href="content.html?type=berita" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-blue-50 text-blue-600 group-hover:bg-blue-600 group-hover:text-white">
|
||||
<i class="fas fa-id-card"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue"
|
||||
data-i18n="link_official_contact">Kontak Resmi</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_contact_desc">
|
||||
Direktori kontak staf</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="https://mail.google.com/mail/?view=cm&fs=1&to=info@informatika.untan.ac.id" target="_blank" rel="noopener noreferrer" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-green-50 text-green-600 group-hover:bg-green-600 group-hover:text-white">
|
||||
<i class="fas fa-comment-dots"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue"
|
||||
data-i18n="link_feedback">Saran & Masukan</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_feedback_desc">Kirim
|
||||
feedback Anda</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="bg-slate-50 rounded-xl p-5">
|
||||
<h4 class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4"
|
||||
data-i18n="follow_us">Ikuti Kami</h4>
|
||||
<div class="flex gap-3">
|
||||
<a href="https://instagram.com/informatika.untan" target="_blank"
|
||||
class="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-xl flex items-center justify-center text-white hover:scale-110 transition-transform">
|
||||
<i class="fab fa-instagram text-xl"></i>
|
||||
</a>
|
||||
<a href="#"
|
||||
class="w-12 h-12 bg-gradient-to-br from-red-500 to-red-600 rounded-xl flex items-center justify-center text-white hover:scale-110 transition-transform">
|
||||
<i class="fab fa-youtube text-xl"></i>
|
||||
</a>
|
||||
<a href="#"
|
||||
class="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center text-white hover:scale-110 transition-transform">
|
||||
<i class="fab fa-facebook-f text-xl"></i>
|
||||
</a>
|
||||
<a href="#"
|
||||
class="w-12 h-12 bg-gradient-to-br from-sky-400 to-sky-500 rounded-xl flex items-center justify-center text-white hover:scale-110 transition-transform">
|
||||
<i class="fab fa-twitter text-xl"></i>
|
||||
</a>
|
||||
<a href="#"
|
||||
class="w-12 h-12 bg-gradient-to-br from-blue-600 to-blue-700 rounded-xl flex items-center justify-center text-white hover:scale-110 transition-transform">
|
||||
<i class="fab fa-linkedin-in text-xl"></i>
|
||||
</a>
|
||||
<a href="#"
|
||||
class="w-12 h-12 bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl flex items-center justify-center text-white hover:scale-110 transition-transform">
|
||||
<i class="fab fa-tiktok text-xl"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- End mega-menu-container -->
|
||||
</nav>
|
||||
|
||||
<!-- Overlay for mega menu -->
|
||||
<div id="mega-overlay" class="fixed inset-0 bg-black/30 backdrop-blur-sm z-30"></div>
|
||||
</div>
|
||||
|
||||
<div id="mobile-menu"
|
||||
class="fixed inset-0 bg-slate-900 z-[60] transform -translate-x-full lg:hidden flex flex-col transition-transform duration-300"
|
||||
role="dialog" aria-modal="true" aria-label="Menu navigasi mobile">
|
||||
<div class="p-4 flex justify-between items-center border-b border-white/10 bg-untan-navy">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="font-bold text-lg text-white font-serif">Menu Utama</span>
|
||||
<button
|
||||
class="language-toggle-btn px-2 py-1 rounded-sm border border-white/20 text-xs text-white hover:bg-white/10 transition-colors flex items-center gap-2"
|
||||
aria-label="Ganti bahasa">
|
||||
<span class="font-bold">ID</span> <span class="text-white/40" aria-hidden="true">|</span> EN
|
||||
</button>
|
||||
<button
|
||||
class="w-8 h-8 rounded-sm bg-white/10 hover:bg-white/20 flex items-center justify-center text-white transition-colors"
|
||||
aria-label="Pengaturan aksesibilitas" data-accessibility-toggle>
|
||||
<i class="fas fa-universal-access" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button id="close-mobile-menu"
|
||||
class="w-8 h-8 rounded-sm bg-white/10 flex items-center justify-center text-white"
|
||||
aria-label="Tutup menu navigasi">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-5 space-y-4 bg-slate-50">
|
||||
<a href="../../index.html" class="block text-base font-bold text-slate-800 py-1 border-b border-slate-200"
|
||||
data-i18n="nav_home">Beranda</a>
|
||||
|
||||
<!-- Tentang Kami -->
|
||||
<div class="pt-2">
|
||||
<span class="text-xs font-bold text-slate-400 uppercase font-serif" data-i18n="nav_about">Tentang
|
||||
Kami</span>
|
||||
<div class="pl-2 border-l-2 border-slate-100 mt-1 space-y-1">
|
||||
<a href="content.html?type=berita" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_history">Sejarah & Tonggak</a>
|
||||
<a href="content.html?type=berita" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_org">Struktur Organisasi</a>
|
||||
<a href="content.html?type=berita" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_vision">Visi & Misi</a>
|
||||
<a href="content.html?type=berita" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_partnership">Kerjasama & MoU</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Program -->
|
||||
<div class="pt-2 border-t border-slate-200">
|
||||
<span class="text-xs font-bold text-slate-400 uppercase font-serif"
|
||||
data-i18n="nav_program">Program</span>
|
||||
<div class="pl-2 border-l-2 border-slate-100 mt-1 space-y-1">
|
||||
<a href="subject-and-courses.html" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_academic">Akademik</a>
|
||||
<a href="content.html?type=berita" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_accreditation">Akreditasi</a>
|
||||
<a href="graduate-profile.html" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_graduate">Profil Lulusan</a>
|
||||
<a href="subject-and-courses.html" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_courses">Mata Kuliah</a>
|
||||
<a href="academic-guidelines.html" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_guideline">Panduan Akademik</a>
|
||||
<a href="content.html?type=berita" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_statistics">Informatika Angka</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SDM -->
|
||||
<div class="pt-2 border-t border-slate-200">
|
||||
<span class="text-xs font-bold text-slate-400 uppercase font-serif" data-i18n="nav_sdm">SDM</span>
|
||||
<div class="pl-2 border-l-2 border-slate-100 mt-1 space-y-1">
|
||||
<a href="people-based-on-kk.html"
|
||||
class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue font-medium"
|
||||
data-i18n="btn_all_lecturers">Semua Dosen</a>
|
||||
<a href="people-based-on-kk.html" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="kk_ai">Kecerdasan Buatan</a>
|
||||
<a href="people-based-on-kk.html" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="kk_se">Rekayasa Perangkat Lunak</a>
|
||||
<a href="people-based-on-kk.html" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="kk_network">Jaringan & Keamanan</a>
|
||||
<a href="people-based-on-kk.html" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="kk_is">Sistem Informasi</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fasilitas -->
|
||||
<div class="pt-2 border-t border-slate-200">
|
||||
<span class="text-xs font-bold text-slate-400 uppercase font-serif"
|
||||
data-i18n="nav_facilities">Fasilitas</span>
|
||||
<div class="pl-2 border-l-2 border-slate-100 mt-1 space-y-1">
|
||||
<a href="content.html?type=rubrik" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="mega_lab">Laboratorium</a>
|
||||
<a href="content.html?type=rubrik" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="mega_dc">Data Center</a>
|
||||
<a href="content.html?type=rubrik" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="mega_eresources">E-Resources</a>
|
||||
<a href="ifuntanhub.html"
|
||||
class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue">IFUNTANHUB</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Karya & Agenda -->
|
||||
<div class="pt-2 border-t border-slate-200">
|
||||
<span class="text-xs font-bold text-slate-400 uppercase font-serif" data-i18n="nav_works">Karya &
|
||||
Agenda</span>
|
||||
<div class="pl-2 border-l-2 border-slate-100 mt-1 space-y-1">
|
||||
<a href="karya.html" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_all_karya">Semua Karya</a>
|
||||
<a href="karya.html?filter=penelitian"
|
||||
class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_research">Riset</a>
|
||||
<a href="karya.html?filter=pkm" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_community">Pengabdian Masyarakat</a>
|
||||
<a href="content.html?type=rubrik" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_achievements">Prestasi</a>
|
||||
<a href="content.html?type=berita" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_info_agenda">Info & Agenda</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kontak -->
|
||||
<div class="pt-2 border-t border-slate-200 pb-8">
|
||||
<span class="text-xs font-bold text-slate-400 uppercase font-serif"
|
||||
data-i18n="nav_contact">Kontak</span>
|
||||
<div class="pl-2 border-l-2 border-slate-100 mt-1 space-y-1">
|
||||
<a href="content.html?type=berita" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_official_contact">Kontak Resmi</a>
|
||||
<a href="https://mail.google.com/mail/?view=cm&fs=1&to=info@informatika.untan.ac.id" target="_blank" rel="noopener noreferrer"
|
||||
class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_feedback">Saran & Masukan</a>
|
||||
<a href="https://instagram.com/informatika.untan" target="_blank" rel="noopener noreferrer"
|
||||
class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue flex items-center gap-2">
|
||||
<i class="fab fa-instagram text-pink-500"></i> Instagram
|
||||
</a>
|
||||
<a href="#"
|
||||
class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue flex items-center gap-2">
|
||||
<i class="fab fa-youtube text-red-500"></i> YouTube
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { navigationTemplate } from './navigation-template.js';
|
||||
import { mainContentTemplate } from './main-content-template.js';
|
||||
import { footerTemplate } from './footer-template.js';
|
||||
|
||||
export function pageTemplate() {
|
||||
return [navigationTemplate(), mainContentTemplate(), footerTemplate()].join('');
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
export class DateRangeCalculator {
|
||||
static clearTime(date) {
|
||||
if (!date) return null;
|
||||
const d = new Date(date);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
static datesEqual(left, right) {
|
||||
if (!left || !right) return false;
|
||||
return left.getFullYear() === right.getFullYear()
|
||||
&& left.getMonth() === right.getMonth()
|
||||
&& left.getDate() === right.getDate();
|
||||
}
|
||||
|
||||
static calculatePreset(presetName, todayInput = new Date()) {
|
||||
const today = this.clearTime(todayInput);
|
||||
let start = null;
|
||||
let end = null;
|
||||
|
||||
switch (presetName) {
|
||||
case 'today':
|
||||
start = new Date(today);
|
||||
end = new Date(today);
|
||||
break;
|
||||
case 'yesterday':
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - 1);
|
||||
end = new Date(start);
|
||||
break;
|
||||
case 'last7':
|
||||
end = new Date(today);
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - 6);
|
||||
break;
|
||||
case 'last14':
|
||||
end = new Date(today);
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - 13);
|
||||
break;
|
||||
case 'last30':
|
||||
end = new Date(today);
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - 29);
|
||||
break;
|
||||
case 'thisWeek': {
|
||||
end = new Date(today);
|
||||
start = new Date(today);
|
||||
const day = today.getDay();
|
||||
const diff = today.getDate() - day + (day === 0 ? -6 : 1);
|
||||
start.setDate(diff);
|
||||
break;
|
||||
}
|
||||
case 'lastWeek': {
|
||||
const temp = new Date(today);
|
||||
const day = temp.getDay();
|
||||
const diff = temp.getDate() - day + (day === 0 ? -6 : 1);
|
||||
start = new Date(temp);
|
||||
start.setDate(diff - 7);
|
||||
end = new Date(temp);
|
||||
end.setDate(diff - 1);
|
||||
break;
|
||||
}
|
||||
case 'thisMonth':
|
||||
start = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
end = new Date(today);
|
||||
break;
|
||||
case 'lastMonth':
|
||||
start = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
||||
end = new Date(today.getFullYear(), today.getMonth(), 0);
|
||||
break;
|
||||
case 'all':
|
||||
default:
|
||||
start = null;
|
||||
end = null;
|
||||
break;
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
static formatDisplay(date) {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
return `${day}/${month}/${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
static parseDisplayDate(value) {
|
||||
const match = String(value || '').trim().match(/^(\d{1,2})[\/\-.](\d{1,2})[\/\-.](\d{4})$/);
|
||||
if (!match) return null;
|
||||
|
||||
const day = Number.parseInt(match[1], 10);
|
||||
const month = Number.parseInt(match[2], 10) - 1;
|
||||
const year = Number.parseInt(match[3], 10);
|
||||
if (month < 0 || month > 11 || day < 1 || day > 31 || year < 1000 || year > 9999) return null;
|
||||
|
||||
const date = new Date(year, month, day);
|
||||
if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) return null;
|
||||
return date;
|
||||
}
|
||||
|
||||
static toDateKey(date) {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
export class DocumentFormatter {
|
||||
static MONTHS_ID = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'];
|
||||
|
||||
static CATEGORY_RULES = [
|
||||
{ key: 'skripsi', match: ['skripsi', 'tugas akhir', 'ta'] },
|
||||
{ key: 'akademik', match: ['akademik', 'kalender', 'kaldik'] },
|
||||
{ key: 'praktik', match: ['praktik', 'praktek', 'kerja praktik', 'kp'] },
|
||||
];
|
||||
|
||||
static TAG_ABBREVIATIONS = new Set(['kp', 'sop', 'ta', 'krs', 'khs', 'sks', 'pkl', 'mbkm']);
|
||||
|
||||
static FILE_TYPE_RULES = [
|
||||
{ extensions: ['pdf'], mimeTypes: ['application/pdf'], icon: 'fas fa-file-pdf', css: 'doc-type-pdf' },
|
||||
{
|
||||
extensions: ['doc', 'docx'],
|
||||
mimeTypes: [
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
],
|
||||
fallbackType: 'docx',
|
||||
icon: 'fas fa-file-word',
|
||||
css: 'doc-type-docx',
|
||||
},
|
||||
{
|
||||
extensions: ['ppt', 'pptx'],
|
||||
mimeTypes: [
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
],
|
||||
fallbackType: 'pptx',
|
||||
icon: 'fas fa-file-powerpoint',
|
||||
css: 'doc-type-pptx',
|
||||
},
|
||||
{
|
||||
extensions: ['xls', 'xlsx'],
|
||||
mimeTypes: [
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
],
|
||||
fallbackType: 'xlsx',
|
||||
icon: 'fas fa-file-excel',
|
||||
css: 'doc-type-xlsx',
|
||||
},
|
||||
{ extensions: ['csv'], mimeTypes: ['text/csv'], icon: 'fas fa-file-csv', css: 'doc-type-xlsx' },
|
||||
{ extensions: ['txt', 'rtf', 'md'], mimeTypes: ['text/plain', 'application/rtf'], icon: 'fas fa-file-alt', css: 'doc-type-txt' },
|
||||
{
|
||||
extensions: ['zip', 'rar', '7z', 'tar', 'gz'],
|
||||
mimeTypes: ['application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed', 'application/gzip'],
|
||||
icon: 'fas fa-file-archive',
|
||||
css: 'doc-type-archive',
|
||||
},
|
||||
{
|
||||
extensions: ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'],
|
||||
mimePrefix: 'image/',
|
||||
icon: 'fas fa-file-image',
|
||||
css: 'doc-type-image',
|
||||
},
|
||||
];
|
||||
|
||||
static formatDate(iso) {
|
||||
if (!iso) return '';
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = this.MONTHS_ID[date.getMonth()];
|
||||
return `${day} ${month} ${date.getFullYear()}`;
|
||||
}
|
||||
|
||||
static categoryFromTags(tags) {
|
||||
if (!tags || tags.length === 0) return 'umum';
|
||||
const lower = tags.map(tag => tag.toLowerCase());
|
||||
for (const rule of this.CATEGORY_RULES) {
|
||||
if (rule.match.some(match => lower.includes(match))) return rule.key;
|
||||
}
|
||||
return 'umum';
|
||||
}
|
||||
|
||||
static fileTypeFromName(name) {
|
||||
return this.fileInfoFromName(name).type;
|
||||
}
|
||||
|
||||
static fileInfoFromName(name) {
|
||||
const extension = this.extensionFromName(name);
|
||||
const rule = this.FILE_TYPE_RULES.find(item => item.extensions.includes(extension));
|
||||
|
||||
if (!rule) {
|
||||
return {
|
||||
type: extension || 'file',
|
||||
icon: 'fas fa-file',
|
||||
css: 'doc-type-file',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: extension,
|
||||
icon: rule.icon,
|
||||
css: rule.css,
|
||||
};
|
||||
}
|
||||
|
||||
static fileInfoFromItem(item) {
|
||||
const file = item?.file && typeof item.file === 'object' ? item.file : {};
|
||||
const nameCandidates = [
|
||||
file.filename_download,
|
||||
file.filename_disk,
|
||||
item?.nama_berkas,
|
||||
];
|
||||
|
||||
for (const name of nameCandidates) {
|
||||
const info = this.fileInfoFromName(name);
|
||||
if (info.type !== 'file') return info;
|
||||
}
|
||||
|
||||
return this.fileInfoFromMime(file.type);
|
||||
}
|
||||
|
||||
static fileInfoFromMime(type) {
|
||||
const mime = String(type || '').trim().toLowerCase();
|
||||
if (!mime) return this.fileInfoFromName('');
|
||||
|
||||
const rule = this.FILE_TYPE_RULES.find(item => (
|
||||
item.mimeTypes?.includes(mime) || (item.mimePrefix && mime.startsWith(item.mimePrefix))
|
||||
));
|
||||
|
||||
if (!rule) return this.fileInfoFromName('');
|
||||
|
||||
return {
|
||||
type: rule.fallbackType || rule.extensions[0],
|
||||
icon: rule.icon,
|
||||
css: rule.css,
|
||||
};
|
||||
}
|
||||
|
||||
static extensionFromName(name) {
|
||||
const normalized = String(name || '').trim().toLowerCase();
|
||||
const lastSegment = normalized.split(/[\\/]/).pop() || '';
|
||||
const extensionStart = lastSegment.lastIndexOf('.');
|
||||
|
||||
if (extensionStart <= 0 || extensionStart === lastSegment.length - 1) return '';
|
||||
return lastSegment.slice(extensionStart + 1);
|
||||
}
|
||||
|
||||
static downloadFilename(name, type) {
|
||||
const ext = `.${type}`;
|
||||
if (!name) return `dokumen${ext}`;
|
||||
return name.toLowerCase().endsWith(ext.toLowerCase()) ? name : `${name}${ext}`;
|
||||
}
|
||||
|
||||
static downloadFilenameFromItem(item) {
|
||||
const file = item?.file && typeof item.file === 'object' ? item.file : {};
|
||||
const originalFilename = file.filename_download || file.filename_disk;
|
||||
if (originalFilename) return originalFilename;
|
||||
|
||||
const fileType = this.fileInfoFromItem(item).type;
|
||||
return this.downloadFilename(item?.nama_berkas || 'dokumen', fileType);
|
||||
}
|
||||
|
||||
static escapeHtml(str) {
|
||||
if (str == null) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
static escapeAttr(str) {
|
||||
return this.escapeHtml(str);
|
||||
}
|
||||
|
||||
static formatTag(tag) {
|
||||
const value = String(tag).trim().replace(/\s+/g, ' ');
|
||||
if (!value) return value;
|
||||
|
||||
return value.split(' ').map(word => {
|
||||
const lower = word.toLowerCase();
|
||||
if (this.TAG_ABBREVIATIONS.has(lower) || /^[A-Z0-9]{2,}$/.test(word)) {
|
||||
return lower.toUpperCase();
|
||||
}
|
||||
|
||||
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
static parseTags(raw) {
|
||||
if (!raw) return [];
|
||||
const str = String(raw).trim();
|
||||
if (!str) return [];
|
||||
|
||||
if (str.startsWith('[')) {
|
||||
try {
|
||||
const arr = JSON.parse(str);
|
||||
if (Array.isArray(arr)) {
|
||||
return arr.map(tag => String(tag).trim()).filter(Boolean);
|
||||
}
|
||||
} catch {
|
||||
// Fall through to comma-separated parsing.
|
||||
}
|
||||
}
|
||||
|
||||
return str.split(',').map(tag => tag.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import './viewer/viewer-app.js';
|
||||
@@ -0,0 +1,122 @@
|
||||
import { FileDownloader } from '../services/file-downloader.js';
|
||||
import { ViewerApi } from './viewer-api.js';
|
||||
|
||||
export class FileViewer {
|
||||
constructor() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
this.id = params.get('id');
|
||||
this.name = params.get('name') || 'Dokumen';
|
||||
this.type = (params.get('type') || 'pdf').toLowerCase();
|
||||
|
||||
this.titleEl = document.getElementById('viewer-title');
|
||||
this.typeBadgeEl = document.getElementById('viewer-type');
|
||||
this.downloadEl = document.getElementById('viewer-download');
|
||||
this.contentEl = document.getElementById('viewer-content');
|
||||
|
||||
this.assetUrl = this.id ? ViewerApi.assetUrl(this.id) : null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.id) {
|
||||
this._renderError('Parameter berkas tidak ditemukan di URL.');
|
||||
return;
|
||||
}
|
||||
|
||||
document.title = `${this.name} — Pratinjau Dokumen`;
|
||||
this.titleEl.textContent = this.name;
|
||||
this.typeBadgeEl.textContent = this.type.toUpperCase();
|
||||
this._setupDownload();
|
||||
|
||||
try {
|
||||
if (this._isOfficeType()) {
|
||||
this._renderOfficeOnline();
|
||||
} else if (this.type === 'pdf') {
|
||||
this._renderPdf();
|
||||
} else {
|
||||
this._renderUnsupported();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
this._renderError('Gagal memuat pratinjau berkas. Silakan unduh berkas untuk membuka secara lokal.');
|
||||
}
|
||||
}
|
||||
|
||||
_setupDownload() {
|
||||
const ext = `.${this.type}`;
|
||||
const filename = this.name.toLowerCase().endsWith(ext) ? this.name : `${this.name}${ext}`;
|
||||
|
||||
this.downloadEl.href = FileDownloader.originalDownloadUrl(this.assetUrl);
|
||||
this.downloadEl.download = filename;
|
||||
this.downloadEl.style.cursor = 'pointer';
|
||||
this.downloadEl.addEventListener('click', async event => {
|
||||
event.preventDefault();
|
||||
const originalHtml = this.downloadEl.innerHTML;
|
||||
this.downloadEl.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Mengunduh…';
|
||||
this.downloadEl.style.pointerEvents = 'none';
|
||||
try {
|
||||
await FileDownloader.download(this.assetUrl, filename);
|
||||
} catch (err) {
|
||||
console.warn('Blob download failed, falling back:', err);
|
||||
window.open(FileDownloader.originalDownloadUrl(this.assetUrl), '_blank');
|
||||
} finally {
|
||||
this.downloadEl.innerHTML = originalHtml;
|
||||
this.downloadEl.style.pointerEvents = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_isOfficeType() {
|
||||
return ['doc', 'docx', 'ppt', 'pptx', 'xls', 'xlsx'].includes(this.type);
|
||||
}
|
||||
|
||||
_renderOfficeOnline() {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.className = 'viewer-office-frame';
|
||||
iframe.title = this.name;
|
||||
iframe.src = `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(this.assetUrl)}`;
|
||||
iframe.setAttribute('frameborder', '0');
|
||||
iframe.setAttribute('allowfullscreen', '');
|
||||
this.contentEl.replaceChildren(iframe);
|
||||
}
|
||||
|
||||
_renderPdf() {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.className = 'viewer-pdf-frame';
|
||||
iframe.title = this.name;
|
||||
iframe.src = this.assetUrl;
|
||||
this.contentEl.replaceChildren(iframe);
|
||||
}
|
||||
|
||||
_renderUnsupported() {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'viewer-error';
|
||||
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'fas fa-file';
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.textContent = `Pratinjau tidak tersedia untuk format ${this.type.toUpperCase()}. Silakan unduh berkas.`;
|
||||
|
||||
wrapper.append(icon, span);
|
||||
this.contentEl.replaceChildren(wrapper);
|
||||
}
|
||||
|
||||
_renderError(message) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'viewer-error';
|
||||
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'fas fa-exclamation-triangle';
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.textContent = message;
|
||||
|
||||
const back = document.createElement('a');
|
||||
back.href = 'index.html#repository';
|
||||
back.className = 'btn-back-mini';
|
||||
back.textContent = 'Kembali ke Repositori';
|
||||
|
||||
wrapper.append(icon, span, back);
|
||||
this.contentEl.replaceChildren(wrapper);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export class ViewerApi {
|
||||
static API_BASE = 'https://api.ifuntanhub.dev';
|
||||
|
||||
static assetUrl(uuid) {
|
||||
return `${this.API_BASE}/assets/${uuid}`;
|
||||
}
|
||||
|
||||
static async fetchBlob(url, onProgress) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Gagal mengambil berkas (HTTP ${response.status}).`);
|
||||
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
if (onProgress && contentLength && response.body) {
|
||||
const total = Number.parseInt(contentLength, 10);
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let received = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
onProgress(received / total);
|
||||
}
|
||||
return new Blob(chunks);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { FileViewer } from './file-viewer.js';
|
||||
import { ViewerPrefetch } from './viewer-prefetch.js';
|
||||
|
||||
ViewerPrefetch.fromLocation();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new FileViewer().init();
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ViewerApi } from './viewer-api.js';
|
||||
|
||||
export class ViewerPrefetch {
|
||||
static fromLocation(location = window.location) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const id = params.get('id');
|
||||
if (!id) return;
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'prefetch';
|
||||
link.as = 'fetch';
|
||||
link.crossOrigin = 'anonymous';
|
||||
link.href = ViewerApi.assetUrl(id);
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { DateRangeCalculator } from '../js/utils/date-range-calculator.js';
|
||||
import { PaginationModel } from '../js/models/pagination-model.js';
|
||||
|
||||
test('calculates date presets using an injected current day', () => {
|
||||
const today = new Date('2026-06-04T12:00:00+07:00');
|
||||
|
||||
const last7 = DateRangeCalculator.calculatePreset('last7', today);
|
||||
assert.equal(DateRangeCalculator.toDateKey(last7.start), '2026-05-29');
|
||||
assert.equal(DateRangeCalculator.toDateKey(last7.end), '2026-06-04');
|
||||
|
||||
const thisMonth = DateRangeCalculator.calculatePreset('thisMonth', today);
|
||||
assert.equal(DateRangeCalculator.toDateKey(thisMonth.start), '2026-06-01');
|
||||
assert.equal(DateRangeCalculator.toDateKey(thisMonth.end), '2026-06-04');
|
||||
|
||||
const all = DateRangeCalculator.calculatePreset('all', today);
|
||||
assert.equal(all.start, null);
|
||||
assert.equal(all.end, null);
|
||||
});
|
||||
|
||||
test('formats and parses dd/mm/yyyy date input safely', () => {
|
||||
const date = new Date(2026, 5, 4);
|
||||
assert.equal(DateRangeCalculator.formatDisplay(date), '04/06/2026');
|
||||
assert.equal(DateRangeCalculator.toDateKey(date), '2026-06-04');
|
||||
assert.equal(DateRangeCalculator.parseDisplayDate('04/06/2026')?.getFullYear(), 2026);
|
||||
assert.equal(DateRangeCalculator.parseDisplayDate('31/02/2026'), null);
|
||||
assert.equal(DateRangeCalculator.parseDisplayDate('04/06/10000'), null);
|
||||
});
|
||||
|
||||
test('creates stable compact pagination ranges', () => {
|
||||
assert.deepEqual(PaginationModel.range(1, 4), [1, 2, 3, 4]);
|
||||
assert.deepEqual(PaginationModel.range(1, 10), [1, 2, '...', 10]);
|
||||
assert.deepEqual(PaginationModel.range(5, 10), [1, '...', 4, 5, 6, '...', 10]);
|
||||
assert.deepEqual(PaginationModel.range(10, 10), [1, '...', 9, 10]);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { DirectusClient } from '../js/services/directus-client.js';
|
||||
|
||||
test('requests berkas with expanded Directus file metadata', () => {
|
||||
const url = new URL(DirectusClient.itemsUrl('berkas'));
|
||||
|
||||
assert.equal(url.pathname, '/items/berkas');
|
||||
assert.equal(url.searchParams.get('limit'), '-1');
|
||||
assert.equal(url.searchParams.get('fields'), '*,file.id,file.filename_download,file.type,file.filename_disk');
|
||||
});
|
||||
|
||||
test('builds asset and viewer URLs from expanded Directus file objects', () => {
|
||||
const item = {
|
||||
nama_berkas: 'Panduan Penulisan Proposal Kerja Praktek',
|
||||
file: {
|
||||
id: '4976330e-e547-45f6-b9be-afce493c8a82',
|
||||
filename_download: 'Panduan Penulisan Proposal Kerja Praktek.docx',
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
DirectusClient.assetUrl(item.file),
|
||||
'https://api.ifuntanhub.dev/assets/4976330e-e547-45f6-b9be-afce493c8a82',
|
||||
);
|
||||
|
||||
const viewerUrl = new URL(DirectusClient.viewerUrl(item), 'https://example.test/groups/Tapops/');
|
||||
assert.equal(viewerUrl.pathname, '/groups/Tapops/viewer.html');
|
||||
assert.equal(viewerUrl.searchParams.get('id'), '4976330e-e547-45f6-b9be-afce493c8a82');
|
||||
assert.equal(viewerUrl.searchParams.get('name'), 'Panduan Penulisan Proposal Kerja Praktek');
|
||||
assert.equal(viewerUrl.searchParams.get('type'), 'docx');
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { DocumentFormatter } from '../js/utils/document-formatter.js';
|
||||
|
||||
test('parses comma-separated and JSON string tags', () => {
|
||||
assert.deepEqual(DocumentFormatter.parseTags('akademik, panduan, 2026'), [
|
||||
'akademik',
|
||||
'panduan',
|
||||
'2026',
|
||||
]);
|
||||
assert.deepEqual(DocumentFormatter.parseTags('["kerja praktik","kp"]'), [
|
||||
'kerja praktik',
|
||||
'kp',
|
||||
]);
|
||||
});
|
||||
|
||||
test('formats tag labels without breaking abbreviations', () => {
|
||||
assert.equal(DocumentFormatter.formatTag('SOP'), 'SOP');
|
||||
assert.equal(DocumentFormatter.formatTag('kp'), 'KP');
|
||||
assert.equal(DocumentFormatter.formatTag('kerja praktek'), 'Kerja Praktek');
|
||||
assert.equal(DocumentFormatter.formatTag('Kerja Praktek'), 'Kerja Praktek');
|
||||
assert.equal(DocumentFormatter.formatTag('umum'), 'Umum');
|
||||
});
|
||||
|
||||
test('maps parsed tags to fixed repository categories', () => {
|
||||
assert.equal(DocumentFormatter.categoryFromTags(['TA']), 'skripsi');
|
||||
assert.equal(DocumentFormatter.categoryFromTags(['Kalender']), 'akademik');
|
||||
assert.equal(DocumentFormatter.categoryFromTags(['kp']), 'praktik');
|
||||
assert.equal(DocumentFormatter.categoryFromTags(['surat']), 'umum');
|
||||
assert.equal(DocumentFormatter.categoryFromTags([]), 'umum');
|
||||
});
|
||||
|
||||
test('derives file type and safe download filename from document name', () => {
|
||||
assert.equal(DocumentFormatter.fileTypeFromName('Panduan Akademik.pdf'), 'pdf');
|
||||
assert.equal(DocumentFormatter.fileTypeFromName('Form KP.DOCX'), 'docx');
|
||||
assert.equal(DocumentFormatter.fileTypeFromName('Catatan.txt'), 'txt');
|
||||
assert.equal(DocumentFormatter.fileTypeFromName('Materi Seminar.pptx'), 'pptx');
|
||||
assert.equal(DocumentFormatter.downloadFilename('Form KP', 'docx'), 'Form KP.docx');
|
||||
assert.equal(DocumentFormatter.downloadFilename('Template lama.DOC', 'doc'), 'Template lama.DOC');
|
||||
assert.equal(DocumentFormatter.downloadFilename('Kalender.pdf', 'pdf'), 'Kalender.pdf');
|
||||
});
|
||||
|
||||
test('maps supported file names to display metadata for cards', () => {
|
||||
assert.deepEqual(DocumentFormatter.fileInfoFromName('Panduan Akademik.pdf'), {
|
||||
type: 'pdf',
|
||||
icon: 'fas fa-file-pdf',
|
||||
css: 'doc-type-pdf',
|
||||
});
|
||||
assert.deepEqual(DocumentFormatter.fileInfoFromName('Form KP.DOCX'), {
|
||||
type: 'docx',
|
||||
icon: 'fas fa-file-word',
|
||||
css: 'doc-type-docx',
|
||||
});
|
||||
assert.deepEqual(DocumentFormatter.fileInfoFromName('Catatan.txt'), {
|
||||
type: 'txt',
|
||||
icon: 'fas fa-file-alt',
|
||||
css: 'doc-type-txt',
|
||||
});
|
||||
assert.deepEqual(DocumentFormatter.fileInfoFromName('Materi Seminar.pptx'), {
|
||||
type: 'pptx',
|
||||
icon: 'fas fa-file-powerpoint',
|
||||
css: 'doc-type-pptx',
|
||||
});
|
||||
assert.deepEqual(DocumentFormatter.fileInfoFromName('Lampiran.7z'), {
|
||||
type: '7z',
|
||||
icon: 'fas fa-file-archive',
|
||||
css: 'doc-type-archive',
|
||||
});
|
||||
});
|
||||
|
||||
test('derives file metadata from expanded Directus file records when display title has no extension', () => {
|
||||
const pdfItem = {
|
||||
nama_berkas: 'SOP Kerja Praktek',
|
||||
file: {
|
||||
filename_download: 'SOP Kerja Praktek.pdf',
|
||||
type: 'application/pdf',
|
||||
},
|
||||
};
|
||||
const docxItem = {
|
||||
nama_berkas: 'Panduan Penulisan Proposal Kerja Praktek',
|
||||
file: {
|
||||
filename_download: 'Panduan Penulisan Proposal Kerja Praktek.docx',
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(DocumentFormatter.fileInfoFromItem(pdfItem), {
|
||||
type: 'pdf',
|
||||
icon: 'fas fa-file-pdf',
|
||||
css: 'doc-type-pdf',
|
||||
});
|
||||
assert.deepEqual(DocumentFormatter.fileInfoFromItem(docxItem), {
|
||||
type: 'docx',
|
||||
icon: 'fas fa-file-word',
|
||||
css: 'doc-type-docx',
|
||||
});
|
||||
assert.equal(DocumentFormatter.downloadFilenameFromItem(pdfItem), 'SOP Kerja Praktek.pdf');
|
||||
assert.equal(DocumentFormatter.downloadFilenameFromItem(docxItem), 'Panduan Penulisan Proposal Kerja Praktek.docx');
|
||||
});
|
||||
|
||||
test('falls back to MIME type when Directus filename metadata has no extension', () => {
|
||||
assert.deepEqual(DocumentFormatter.fileInfoFromItem({
|
||||
nama_berkas: 'Berkas Presentasi',
|
||||
file: { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' },
|
||||
}), {
|
||||
type: 'pptx',
|
||||
icon: 'fas fa-file-powerpoint',
|
||||
css: 'doc-type-pptx',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { FileDownloader } from '../js/services/file-downloader.js';
|
||||
import { FileViewer } from '../js/viewer/file-viewer.js';
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName = 'div') {
|
||||
this.tagName = tagName.toUpperCase();
|
||||
this.children = [];
|
||||
this.attributes = new Map();
|
||||
this.dataset = {};
|
||||
this.eventListeners = new Map();
|
||||
this.style = {};
|
||||
this.className = '';
|
||||
this.href = '';
|
||||
this.src = '';
|
||||
this.title = '';
|
||||
this.textContent = '';
|
||||
this.innerHTML = '';
|
||||
this.download = '';
|
||||
}
|
||||
|
||||
append(...nodes) {
|
||||
this.children.push(...nodes);
|
||||
}
|
||||
|
||||
appendChild(node) {
|
||||
this.children.push(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
prepend(node) {
|
||||
this.children.unshift(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
removeChild(node) {
|
||||
this.children = this.children.filter(child => child !== node);
|
||||
return node;
|
||||
}
|
||||
|
||||
replaceChildren(...nodes) {
|
||||
this.children = [...nodes];
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.removed = true;
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
}
|
||||
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name);
|
||||
}
|
||||
|
||||
removeAttribute(name) {
|
||||
this.attributes.delete(name);
|
||||
}
|
||||
|
||||
addEventListener(type, handler) {
|
||||
this.eventListeners.set(type, handler);
|
||||
}
|
||||
|
||||
click() {
|
||||
this.clicked = true;
|
||||
}
|
||||
}
|
||||
|
||||
function installViewerDom(search) {
|
||||
const previous = {
|
||||
document: globalThis.document,
|
||||
window: globalThis.window,
|
||||
URL: globalThis.URL,
|
||||
fetch: globalThis.fetch,
|
||||
setTimeout: globalThis.setTimeout,
|
||||
consoleError: console.error,
|
||||
consoleWarn: console.warn,
|
||||
};
|
||||
const elements = {
|
||||
title: new FakeElement('h1'),
|
||||
type: new FakeElement('span'),
|
||||
download: new FakeElement('a'),
|
||||
content: new FakeElement('main'),
|
||||
};
|
||||
const body = new FakeElement('body');
|
||||
const created = [];
|
||||
|
||||
globalThis.document = {
|
||||
title: '',
|
||||
body,
|
||||
getElementById(id) {
|
||||
return {
|
||||
'viewer-title': elements.title,
|
||||
'viewer-type': elements.type,
|
||||
'viewer-download': elements.download,
|
||||
'viewer-content': elements.content,
|
||||
}[id];
|
||||
},
|
||||
createElement(tagName) {
|
||||
const node = new FakeElement(tagName);
|
||||
created.push(node);
|
||||
return node;
|
||||
},
|
||||
};
|
||||
globalThis.window = {
|
||||
location: { search },
|
||||
open(url) {
|
||||
this.openedUrl = url;
|
||||
},
|
||||
};
|
||||
globalThis.URL = {
|
||||
createObjectURL() {
|
||||
return 'blob:tapops-download';
|
||||
},
|
||||
revokeObjectURL(url) {
|
||||
this.revokedUrl = url;
|
||||
},
|
||||
};
|
||||
globalThis.setTimeout = callback => {
|
||||
callback();
|
||||
return 1;
|
||||
};
|
||||
console.error = () => {};
|
||||
console.warn = () => {};
|
||||
|
||||
return {
|
||||
elements,
|
||||
created,
|
||||
restore() {
|
||||
globalThis.document = previous.document;
|
||||
globalThis.window = previous.window;
|
||||
globalThis.URL = previous.URL;
|
||||
globalThis.fetch = previous.fetch;
|
||||
globalThis.setTimeout = previous.setTimeout;
|
||||
console.error = previous.consoleError;
|
||||
console.warn = previous.consoleWarn;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('repository download fetches the Directus original-file URL', async (t) => {
|
||||
const { created, restore } = installViewerDom('');
|
||||
t.after(restore);
|
||||
|
||||
let fetchedUrl = '';
|
||||
globalThis.fetch = async (url) => {
|
||||
fetchedUrl = url;
|
||||
return {
|
||||
ok: true,
|
||||
blob: async () => new Blob(['docx']),
|
||||
};
|
||||
};
|
||||
|
||||
await FileDownloader.download('https://api.ifuntanhub.dev/assets/docx-id', 'Form KP.docx');
|
||||
|
||||
const clickedAnchor = created.find(node => node.tagName === 'A' && node.clicked);
|
||||
assert.equal(fetchedUrl, 'https://api.ifuntanhub.dev/assets/docx-id?download');
|
||||
assert.equal(clickedAnchor.download, 'Form KP.docx');
|
||||
});
|
||||
|
||||
test('DOCX preview renders Microsoft Office Online Viewer iframe', async (t) => {
|
||||
const { elements, restore } = installViewerDom('?id=docx-id&name=Form%20KP.docx&type=docx');
|
||||
t.after(restore);
|
||||
|
||||
await new FileViewer().init();
|
||||
|
||||
const iframe = elements.content.children[0];
|
||||
const assetUrl = 'https://api.ifuntanhub.dev/assets/docx-id';
|
||||
assert.equal(iframe.tagName, 'IFRAME');
|
||||
assert.equal(iframe.className, 'viewer-office-frame');
|
||||
assert.equal(iframe.src, `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(assetUrl)}`);
|
||||
});
|
||||
|
||||
test('viewer download uses the Directus original-file URL', async (t) => {
|
||||
const { elements, created, restore } = installViewerDom('?id=pdf-id&name=Panduan.pdf&type=pdf');
|
||||
t.after(restore);
|
||||
|
||||
let fetchedUrl = '';
|
||||
globalThis.fetch = async (url) => {
|
||||
fetchedUrl = url;
|
||||
return {
|
||||
ok: true,
|
||||
headers: { get: () => null },
|
||||
blob: async () => new Blob(['pdf']),
|
||||
};
|
||||
};
|
||||
|
||||
await new FileViewer().init();
|
||||
await elements.download.eventListeners.get('click')({ preventDefault() {} });
|
||||
|
||||
const clickedAnchor = created.find(node => node.tagName === 'A' && node.clicked);
|
||||
assert.equal(fetchedUrl, 'https://api.ifuntanhub.dev/assets/pdf-id?download');
|
||||
assert.equal(clickedAnchor.download, 'Panduan.pdf');
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const indexHtml = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
|
||||
const viewerHtml = readFileSync(new URL('../viewer.html', import.meta.url), 'utf8');
|
||||
const repositoryCardsCss = readFileSync(new URL('../css/repository/cards.css', import.meta.url), 'utf8');
|
||||
|
||||
test('Tapops index uses module scripts and no inline behavior', () => {
|
||||
assert.match(indexHtml, /<script type="module" src="js\/app\.js"><\/script>/);
|
||||
assert.doesNotMatch(indexHtml, /<script>\s*\(/);
|
||||
assert.doesNotMatch(indexHtml, /\sonclick=/);
|
||||
});
|
||||
|
||||
test('Tapops viewer uses module scripts and no inline behavior', () => {
|
||||
assert.match(viewerHtml, /<script type="module" src="js\/viewer\/viewer-app\.js"><\/script>/);
|
||||
assert.doesNotMatch(viewerHtml, /<script>\s*\(/);
|
||||
});
|
||||
|
||||
test('Tapops document tags keep the API-provided casing', () => {
|
||||
assert.doesNotMatch(repositoryCardsCss, /\.doc-tag\s*\{[^}]*text-transform:\s*lowercase/is);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pratinjau Dokumen — TAPOPS | Informatika UNTAN</title>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="preconnect" href="https://api.ifuntanhub.dev" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,300;0,400;0,700;0,900;1,300;1,400;1,700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
|
||||
integrity="sha384-iw3OoTErCYJJB9mCa8LNS2hbsQ7M3C0EpIsO/H5+EGAkPGc6rk+V8i04oW/K5xq0"
|
||||
crossorigin="anonymous">
|
||||
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/viewer.css">
|
||||
</head>
|
||||
|
||||
<body class="viewer-body">
|
||||
<header class="viewer-header">
|
||||
<div class="viewer-header-left">
|
||||
<a href="index.html#repository" class="btn-back">
|
||||
<i class="fas fa-arrow-left"></i> Kembali ke Repositori
|
||||
</a>
|
||||
<div class="viewer-title-group">
|
||||
<h1 id="viewer-title" class="viewer-title">Memuat dokumen…</h1>
|
||||
<span id="viewer-type" class="viewer-type-badge"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="viewer-header-right">
|
||||
<a id="viewer-download" class="btn-download" href="#" download>
|
||||
<i class="fas fa-download"></i> Unduh Berkas
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="viewer-content" class="viewer-content">
|
||||
<div class="viewer-loading">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
<span>Memuat pratinjau berkas…</span>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="module" src="js/viewer/viewer-app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user