merge upstream

This commit is contained in:
2026-06-05 03:09:15 +00:00
82 changed files with 17804 additions and 3540 deletions
+6
View File
@@ -1,3 +1,9 @@
node_modules/
dist/
.DS_Store
.env
.env.local
groups/**/.env
groups/**/.env.local
README.md
i18n.js
+414
View File
@@ -569,6 +569,213 @@ const translations = {
kontrib_guideline_3: "Sertakan fakta dan data yang akurat dan dapat diverifikasi.",
kontrib_guideline_4: "Hindari plagiarisme. Karya harus orisinal.",
kontrib_guideline_5: "Tim redaksi berhak menyunting konten untuk keperluan editorial.",
// --- apeNameTeamE (Halaman Beasiswa) ---
ape_breadcrumb_profile: 'Profil',
ape_breadcrumb_scholarship: 'Beasiswa',
ape_hero_badge: 'Program Beasiswa',
ape_hero_title: 'Beasiswa Studi &amp;<br>Penelitian',
ape_hero_subtitle: 'Teknik Informatika Universitas Tanjungpura',
ape_hero_desc: 'Temukan berbagai peluang beasiswa untuk mendukung perjalanan akademik dan penelitian Anda. Program Studi Informatika UNTAN berkomitmen membantu mahasiswa meraih prestasi terbaik.',
ape_hero_cta: 'Lihat Daftar Beasiswa',
ape_stats_total: 'Total Mahasiswa Penerima<br>Beasiswa Semester Ini',
ape_stats_s1: 'Total Penerima Beasiswa<br>Khusus S1',
ape_stats_studi: 'Total Penerima Beasiswa<br>Studi Lanjut',
ape_dir_label: 'Daftar Beasiswa',
ape_dir_title: 'Daftar Beasiswa',
ape_dir_desc: 'Temukan beragam beasiswa dari berbagai sumber resmi yang tersedia untuk mahasiswa aktif, alumni, dan calon mahasiswa Program Studi Informatika UNTAN.',
ape_dir_guide: 'Terima Bantuan Beasiswa?',
ape_filter_s1: 'Khusus S1',
ape_filter_studi: 'Studi Lanjut',
ape_search_placeholder: 'Cari Nama Beasiswa...',
ape_testimonial_title: 'Apa Kata Mereka?',
ape_testimonial_desc: 'Cerita inspiratif dari para penerima beasiswa Program Studi Informatika UNTAN',
ape_cta_title: 'Ingin Mengetahui Info Lebih Lanjut?',
ape_cta_desc: 'Hubungi admin prodi untuk informasi terbaru tentang beasiswa dan program akademik.',
ape_cta_btn1: 'DAFTAR BEASISWA',
ape_cta_btn2: 'Hubungi Kami',
// ── Page ──────────────────────────────────────────────────────────────
grp_habisiniseminar_page_title: 'Penerimaan Mahasiswa Baru Informatika UNTAN | Kelompok 02',
grp_habisiniseminar_brand: 'Informatika UNTAN Penerimaan Mahasiswa Baru',
grp_habisiniseminar_badge: 'Halaman Statis',
grp_habisiniseminar_hero_title_1: 'Penerimaan Mahasiswa Baru',
grp_habisiniseminar_hero_title_2: 'Informatika UNTAN',
grp_habisiniseminar_hero_description: 'Informasi dan jadwal pendaftaran mahasiswa baru melalui jalur SNBP, SNBT, dan Jalur Mandiri UNTAN',
grp_habisiniseminar_meta_group: 'Kelompok 02',
grp_habisiniseminar_about_title: 'Seleksi Program Sarjana',
grp_habisiniseminar_about_desc: 'Program Studi Informatika Universitas Tanjungpura membuka kesempatan bagi putra-putri terbaik bangsa untuk bergabung melalui berbagai jalur seleksi. Kami mencari talenta yang siap menjadi inovator teknologi masa depan.',
// ── Pathways ─────────────────────────────────────────────────────────
grp_habisiniseminar_path_snbp_desc: 'Seleksi Nasional Berdasarkan Prestasi',
grp_habisiniseminar_path_snbt_desc: 'Seleksi Nasional Berdasarkan Tes',
grp_habisiniseminar_path_smmptn_desc: 'Seleksi Mandiri Wilayah Barat',
grp_habisiniseminar_path_mandiri_desc: 'Seleksi Mandiri Universitas Tanjungpura',
grp_habisiniseminar_info_jalur_title: 'Informasi Jalur Seleksi',
// ── Sidebar ──────────────────────────────────────────────────────────
grp_habisiniseminar_sidebar_heading: 'Pilih Jalur',
grp_habisiniseminar_sidebar_data_date: '*Data yang digunakan per tanggal Mei 2026',
grp_habisiniseminar_sidebar_link_general: 'Informasi Umum',
grp_habisiniseminar_sidebar_link_persyaratan: 'Persyaratan',
grp_habisiniseminar_sidebar_link_mekanisme: 'Mekanisme',
grp_habisiniseminar_sidebar_link_jadwal: 'Jadwal',
grp_habisiniseminar_sidebar_link_biaya: 'Biaya Pendaftaran & Pendidikan',
grp_habisiniseminar_sidebar_link_beasiswa: 'Beasiswa',
// ── SNBP ──────────────────────────────────────────────────────────────
grp_habisiniseminar_snbp_title: 'SNBP Seleksi Nasional Berdasarkan Prestasi',
grp_habisiniseminar_info_snbp: 'Seleksi Nasional Berdasarkan Prestasi (SNBP) merupakan jalur penerimaan mahasiswa baru Perguruan Tinggi Negeri (PTN) yang diselenggarakan secara nasional oleh SNPMB. SNBP dilaksanakan melalui mekanisme seleksi berdasarkan hasil belajar di jenjang pendidikan menengah, yang meliputi nilai rapor, prestasi akademik maupun nonakademik, serta kriteria tambahan yang ditetapkan masing-masing PTN. Skema ini bertujuan memberikan kesempatan kepada peserta didik yang memiliki rekam jejak prestasi unggul dan konsisten selama masa studi. SNBP menjadi bagian integral dari upaya pemerintah dalam menciptakan proses seleksi yang objektif, transparan, dan mendorong penguatan pembelajaran di sekolah.',
grp_habisiniseminar_acc_snbp_req_general: 'Persyaratan Umum Mendaftar SNBP',
grp_habisiniseminar_acc_snbp_ketentuan: 'Ketentuan SNPMB',
grp_habisiniseminar_acc_snbp_req_accepted: 'Persyaratan Umum Diterima SNBP',
grp_habisiniseminar_snbp_req_general_body: '<ul class="req-list"><li><span class="req-check"><i class="fas fa-check"></i></span><span>Merupakan siswa SMA/SMK/MA kelas terakhir pada <strong>periode ini</strong> yang <strong>memiliki prestasi unggul</strong>.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Memiliki NISN dan <strong>terdaftar di PDSS</strong>.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Memiliki <strong>nilai rapor</strong> yang telah diisikan di PDSS sesuai dengan ketentuan.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Memiliki <strong>prestasi akademik</strong>.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Memenuhi persyaratan yang ditentukan oleh masing-masing PTN Akademik dan PTN Vokasi.</span></li></ul>',
grp_habisiniseminar_snbp_ketentuan_body: '<ul class="req-list"><li><span class="req-check"><i class="fas fa-check"></i></span><span>Siswa yang telah dinyatakan lulus seleksi Jalur SNBP tidak dapat mendaftar UTBK-SNBT.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Siswa yang dinyatakan lulus seleksi Jalur SNBP tidak dapat mendaftar seleksi Jalur Mandiri pada periode yang sama di PTN manapun.</span></li></ul>',
grp_habisiniseminar_snbp_req_accepted_body: '<ul class="req-list"><li><span class="req-check"><i class="fas fa-check"></i></span><span>Peserta telah lulus dari satuan pendidikan SMA/SMK/MA atau sederajat</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Peserta dinyatakan lulus SNBP pada periode ini</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Peserta lolos verifikasi data serta memenuhi persyaratan lain yang ditetapkan oleh masing-masing PTN</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Peserta melakukan daftar ulang di PTN tempat diterima sesuai dengan ketentuan yang berlaku</span></li></ul>',
grp_habisiniseminar_snbp_mekanisme_text: '<p class="mek-text">Seleksi Nasional Berdasarkan Prestasi (SNBP) di Universitas Tanjungpura mempertimbangkan performa akademik siswa secara menyeluruh melalui nilai rapor, serta pencapaian prestasi sebagai nilai tambah dalam proses seleksi. Komponen yang dinilai meliputi:</p><ul class="mek-check-list"><li><span class="req-check" style="margin-top: 2px"><i class="fas fa-check"></i></span><span><strong>Nilai rapor:</strong> rata-rata nilai seluruh mata pelajaran termasuk pada mata pelajaran yang relevan dengan program studi yang dipilih.</span></li><li><span class="req-check" style="margin-top: 2px"><i class="fas fa-check"></i></span><span><strong>Prestasi akademik dan non-akademik:</strong> pencapaian baik di bidang ilmu pengetahuan (seperti olimpiade atau lomba sains) maupun di luar akademik (seperti olahraga, seni, organisasi, dan kegiatan ekstrakurikuler)</span></li></ul>',
grp_habisiniseminar_tl_snbp_1: 'Pengumuman kuota sekolah dan masa sanggah',
grp_habisiniseminar_tl_snbp_2: 'Registrasi akun dan pengisian PDSS oleh sekolah',
grp_habisiniseminar_tl_snbp_3: 'Registrasi akun SNPMB siswa',
grp_habisiniseminar_tl_snbp_4: 'Pendaftaran SNBP',
grp_habisiniseminar_tl_snbp_5: 'Pengumuman hasil SNBP',
grp_habisiniseminar_tl_snbp_6: 'Unduh kartu peserta SNBP',
grp_habisiniseminar_snbp_fee_desc: 'UKT dibagi dalam beberapa kategori yang disesuaikan dengan kemampuan ekonomi orang tua mahasiswa dan <strong>dibayarkan tiap semester</strong>.',
grp_habisiniseminar_fee_highlight_snbp: 'Mahasiswa yang diterima melalui SNBP dengan menggunakan KIP Kuliah akan diverifikasi dan berpeluang mendapatkan pembebasan UKT sesuai ketentuan KIP Kuliah dan UNTAN.',
grp_habisiniseminar_snbp_beasiswa_desc: 'Para mahasiswa bisa memperoleh beasiswa dari berbagai sumber dengan mekanisme pengajuan sesuai ketentuan lembaga pemberi beasiswa.',
// ── SNBT ──────────────────────────────────────────────────────────────
grp_habisiniseminar_snbt_title: 'SNBT Seleksi Nasional Berdasarkan Tes',
grp_habisiniseminar_info_snbt: 'Seleksi Nasional Berdasarkan Tes (SNBT) merupakan jalur penerimaan mahasiswa baru di Perguruan Tinggi Negeri (PTN) yang menggunakan ujian terstandar secara nasional. Peserta akan mengikuti serangkaian tes untuk mengukur potensi akademik, kemampuan bernalar, serta kesiapan belajar di perguruan tinggi. Penilaian dalam SNBT sepenuhnya didasarkan pada hasil tes yang dikerjakan peserta, sehingga kelulusan tidak ditentukan oleh faktor seperti kondisi ekonomi, asal sekolah (negeri atau swasta), maupun lokasi daerah, melainkan murni berdasarkan skor UTBK. Dengan demikian, seluruh peserta dinilai menggunakan standar yang sama sehingga proses seleksi berlangsung secara adil dan transparan. SNBT menjadi salah satu instrumen utama SNPMB dalam menjamin akses pendidikan tinggi yang meritokratis, akuntabel, dan inklusif.',
grp_habisiniseminar_acc_snbt_req_general: 'Persyaratan Umum Mendaftar SNBT',
grp_habisiniseminar_acc_snbt_utbk: 'Ketentuan SNPMB',
grp_habisiniseminar_acc_snbt_req_prodi: 'Persyaratan Umum Diterima SNBT',
grp_habisiniseminar_snbt_req_general_body: '<ul class="req-list"><li><span class="req-check"><i class="fas fa-check"></i></span><span>Peserta harus <strong>memiliki Akun SNPMB Siswa</strong>. Registrasi Akun SNPMB Siswa dapat dilakukan di Portal SNPMB</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Peserta merupakan <strong>Warga Negara Indonesia (WNI)</strong> yang memiliki <strong>Nomor Induk Kependudukan (NIK)</strong></span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Peserta adalah siswa SMA/SMK/MA atau sederajat pada <strong>kelas terakhir</strong>, atau peserta didik Paket C dengan usia <strong>maksimal 25 tahun</strong></span></li><li class="req-list-item--nested"><span class="req-check"><i class="fas fa-check"></i></span><div class="req-list-content"><p class="req-list-text">Bagi siswa yang <strong>belum memiliki ijazah</strong>, wajib melampirkan <strong>surat keterangan sebagai siswa kelas 12</strong> yang sekurang-kurangnya memuat:</p><ul class="req-sublist"><li>Identitas diri (nama, kelas, NISN, dan NPSN)</li><li>Pas foto terbaru <strong>(berwarna)</strong></li><li>Tanda tangan Kepala Sekolah/Madrasah</li><li>Stempel/cap sekolah</li></ul></div></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Lulusan SMA/sederajat dari luar negeri wajib memiliki ijazah yang telah disetarakan</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Bagi peserta yang memilih program studi bidang <strong>Seni dan/atau Olahraga</strong> wajib mengunggah portofolio</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Peserta memiliki kondisi kesehatan yang tidak mengganggu kelancaran proses studi</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Bagi peserta berkebutuhan khusus <strong>tunanetra</strong> wajib mengunggah <strong>Surat Pernyataan Tuna Netra</strong></span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Peserta wajib membayar biaya UTBK sesuai ketentuan</span></li></ul>',
grp_habisiniseminar_snbt_ketentuan_body: '<ul class="req-list"><li><span class="req-check"><i class="fas fa-check"></i></span><span>Peserta harus memiliki Akun SNPMB Siswa. Registrasi Akun SNPMB Siswa dapat dilakukan di Portal SNPMB</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Peserta merupakan Warga Negara Indonesia (WNI) yang memiliki Nomor Induk Kependudukan (NIK)</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Peserta adalah siswa SMA/SMK/MA atau sederajat pada kelas terakhir, atau peserta didik Paket C tahun 2026 dengan usia maksimal 25 tahun</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Bagi peserta yang memilih program studi bidang Seni dan/atau Olahraga wajib mengunggah portofolio</span></li></ul>',
grp_habisiniseminar_snbt_req_accepted_body: '<ul class="req-list"><li><span class="req-check"><i class="fas fa-check"></i></span><span>Peserta telah lulus dari satuan pendidikan SMA/SMK/MA atau sederajat</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Peserta dinyatakan lulus seleksi SNBT</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Peserta lolos verifikasi data serta memenuhi persyaratan lain yang ditetapkan oleh masing-masing PTN</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Peserta melakukan daftar ulang di PTN tempat diterima sesuai dengan ketentuan yang berlaku</span></li></ul>',
grp_habisiniseminar_snbt_mekanisme_full: '<p class="mek-text">Seleksi Nasional Berdasarkan Tes (SNBT) dilaksanakan melalui Ujian Tulis Berbasis Komputer (UTBK), yaitu tes terstandar untuk mengukur potensi kognitif, penalaran matematika, serta kemampuan literasi dalam bahasa Indonesia dan bahasa Inggris. UTBK bertujuan untuk menilai kemampuan berpikir logis, pemecahan masalah, serta kemampuan belajar yang tinggi sehingga dapat memahami materi yang akan diajarkan pada program studi spesifik. Selain itu, diharapkan terbentuknya kesempatan yang sama kepada semua calon mahasiswa dengan latar belakang ekonomi maupun kualitas pembelajaran sekolah asal yang berbeda.</p><ul class="mek-check-list"><li class="req-list-item--nested"><span class="req-check"><i class="fas fa-check"></i></span><div class="req-list-content"><p class="req-list-text"><strong>Tes Potensi Skolastik (TPS)</strong> mengukur kemampuan kognitif yang diperlukan untuk menunjang keberhasilan di pendidikan formal, khususnya di perguruan tinggi. Aspek yang diujikan meliputi:</p><ul class="req-sublist"><li><strong>Kemampuan penalaran umum</strong>, mencakup penalaran induktif, deduktif, dan kuantitatif</li><li>Pengetahuan dan pemahaman umum</li><li>Pemahaman bacaan dan menulis</li><li><strong>Pengetahuan Kuantitatif</strong>, mencakup pengetahuan dan penguasaan matematika dasar</li></ul></div></li><li class="req-list-item--nested"><span class="req-check"><i class="fas fa-check"></i></span><div class="req-list-content"><p class="req-list-text"><strong>Tes literasi</strong> mengukur kemampuan berpikir peserta menggunakan konsep, prosedur, fakta, dan alat matematika untuk menyelesaikan masalah sehari-hari. Materi yang diujikan meliputi:</p><ul class="req-sublist"><li>Literasi dalam <strong>Bahasa Indonesia</strong></li><li>Literasi dalam <strong>Bahasa Inggris</strong></li><li><strong>Penalaran Matematika</strong></li></ul></div></li></ul><p class="mek-text">Penalaran matematika dalam bagian ini bertujuan menilai kemampuan peserta dalam menggunakan konsep dan prosedur matematika untuk menyelesaikan masalah kontekstual, serta mengevaluasi dan merefleksikan informasi yang diperoleh agar dapat digunakan secara efektif dalam kehidupan sehari-hari. Instrumen tes untuk seleksi masuk PTN disusun oleh para ahli di bidang penilaian pendidikan dengan mempertimbangkan aspek teknis pelaksanaan, termasuk alokasi waktu pengerjaan dan tingkat kesulitan soal, sehingga hasil tes dapat mencerminkan kemampuan peserta secara objektif dan terukur.</p>',
grp_habisiniseminar_tl_snbt_1: 'Registrasi Akun SNPMB Siswa',
grp_habisiniseminar_tl_snbt_2: 'Pendaftaran SNBT',
grp_habisiniseminar_tl_snbt_3: 'Pembayaran Biaya UTBK',
grp_habisiniseminar_tl_snbt_4: 'Pelaksanaan UTBK',
grp_habisiniseminar_tl_snbt_5: 'Pengumuman hasil SNBT',
grp_habisiniseminar_tl_snbt_6: 'Masa unduh sertifikat UTBK',
grp_habisiniseminar_fee_highlight_snbt: 'Mahasiswa yang diterima melalui SNBT dengan menggunakan KIP Kuliah akan diverifikasi dan berpeluang mendapatkan pembebasan UKT sesuai ketentuan KIP Kuliah dan UNTAN.',
grp_habisiniseminar_snbt_beasiswa_desc: 'Mahasiswa SNBT juga berhak mengajukan berbagai program beasiswa yang tersedia sesuai ketentuan masing-masing penyelenggara.',
// ── SMMPTN-Barat ───────────────────────────────────────────────────────
grp_habisiniseminar_smmptn_title: 'SMMPTN Barat Seleksi Mandiri Wilayah Barat',
grp_habisiniseminar_info_smmptn: 'Seleksi Mandiri Masuk Perguruan Tinggi Negeri Wilayah Barat (SMMPTN-Barat) merupakan jalur seleksi mandiri yang diselenggarakan secara bersama oleh sejumlah perguruan tinggi negeri di wilayah barat Indonesia dengan menggunakan ujian berbasis komputer sebagai dasar penilaian. Jalur ini memberikan kesempatan tambahan bagi lulusan SLTA yang belum berhasil diterima melalui jalur Seleksi Nasional Berdasarkan Prestasi (SNBP) maupun Seleksi Nasional Berdasarkan Tes (SNBT), baik karena keterbatasan kuota maupun kurang tepat dalam memilih program studi dan perguruan tinggi negeri. Selain itu, SMMPTN-Barat juga menjadi alternatif bagi peserta yang belum memiliki akun SNPMB untuk tetap dapat mengikuti seleksi masuk perguruan tinggi negeri.',
grp_habisiniseminar_smmptn_persyaratan_list: '<ul class="req-list"><li><span class="req-check"><i class="fas fa-check"></i></span><span>Peserta merupakan lulusan SMA/SMK/MA atau sederajat dalam <strong>3 tahun terakhir dari periode saat ini</strong> atau memiliki ijazah Paket C dengan usia <strong>maksimal 25 tahun</strong></span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Peserta dapat memilih <strong>maksimal 2 (dua)</strong> program studi pada satu PTN atau dua PTN yang berbeda, yang tidak harus berada di lokasi UTBK, dengan urutan pilihan menunjukkan prioritas</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Ujian dilaksanakan dalam bentuk <strong>Ujian Tulis Berbasis Komputer (UTBK)</strong></span></li></ul>',
grp_habisiniseminar_smmptn_mekanisme_full: '<p class="mek-text">Mekanisme seleksi SMM PTN-Barat dilaksanakan melalui ujian tertulis berbasis komputer yang dirancang untuk mengukur kemampuan akademik dan kesiapan calon mahasiswa dalam menempuh pendidikan tinggi. Ujian ini menggunakan pendekatan tes terstandar yang berfokus pada kemampuan kognitif, literasi, dan penalaran, sehingga dapat memastikan peserta memiliki kemampuan berpikir kritis, pemecahan masalah, serta kapasitas belajar yang baik. Adapun jenis materi utama dalam ujian ini, sebagai berikut:</p><ul class="mek-check-list"><li class="req-list-item--nested"><span class="req-check"><i class="fas fa-check"></i></span><div class="req-list-content"><p class="req-list-text"><strong>Tes Potensi Skolastik (TPS)</strong> mengukur kemampuan kognitif yang diperlukan untuk menunjang keberhasilan dalam pendidikan formal, khususnya di perguruan tinggi. Aspek yang diujikan meliputi:</p><ul class="req-sublist"><li>Kemampuan Penalaran Umum</li><li>Kemampuan Kuantitatif yang mencakup pengetahuan dan penguasaan matematika dasar</li><li>Pengetahuan dan Pemahaman umum</li><li>Kemampuan Bacaan dan Menulis</li></ul></div></li><li><span class="req-check" style="margin-top: 2px"><i class="fas fa-check"></i></span><span><strong>Tes literasi</strong> bertujuan untuk mengukur kemampuan peserta dalam memahami, menggunakan, mengevaluasi, dan merefleksikan berbagai jenis teks guna menyelesaikan permasalahan serta mengembangkan kapasitas individu sebagai warga Indonesia dan warga dunia. Tes ini mencakup literasi dalam Bahasa Indonesia dan literasi dalam Bahasa Inggris.</span></li><li><span class="req-check" style="margin-top: 2px"><i class="fas fa-check"></i></span><span><strong>Penalaran Matematika</strong> mengukur kemampuan berpikir menggunakan konsep, prosedur, fakta, dan alat matematika untuk menyelesaikan masalah sehari-hari pada berbagai jenis konteks yang relevan untuk individu sebagai warga negara Indonesia dan warga negara dunia</span></li></ul>',
grp_habisiniseminar_tl_smmptn_1: 'Pendaftaran online',
grp_habisiniseminar_tl_smmptn_2: 'Pelaksanaan UTBK SMMPTN',
grp_habisiniseminar_tl_smmptn_3: 'Pengumuman Hasil Seleksi SMMPTN',
grp_habisiniseminar_smmptn_beasiswa_desc: 'Berbagai beasiswa tersedia bagi mahasiswa yang diterima melalui jalur SMMPTN-Barat, dapat diajukan setelah proses penerimaan selesai.',
// ── Mandiri UNTAN ──────────────────────────────────────────────────────
grp_habisiniseminar_mandiri_title: 'Mandiri UNTAN Seleksi Mandiri Universitas Tanjungpura',
grp_habisiniseminar_info_mandiri: 'Seleksi Mandiri UNTAN merupakan jalur penerimaan mahasiswa baru yang diselenggarakan secara langsung oleh Universitas Tanjungpura. Jalur ini menjadi alternatif bagi calon mahasiswa yang ingin melanjutkan pendidikan di UNTAN melalui mekanisme seleksi yang ditetapkan oleh universitas sesuai dengan ketentuan yang berlaku.',
grp_habisiniseminar_mandiri_persyaratan_list: '<ul class="req-list"><li><span class="req-check"><i class="fas fa-check"></i></span><span>Peserta merupakan lulusan SMA/SMK/MA atau sederajat dalam <strong>3 tahun terakhir dari periode saat ini</strong> atau memiliki ijazah Paket C dengan usia maksimal 25 tahun</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span><strong>Kelas Pagi dan Internasional</strong> terbuka untuk lulusan tiga tahun terakhir.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span><strong>Kelas Sore dan Malam</strong> tidak dibatasi tahun kelulusan dan usia lulusan Paket C.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Peserta memiliki kondisi kesehatan yang memadai untuk mengikuti proses pembelajaran di perguruan tinggi.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Sejumlah program studi, seperti Kedokteran dan Pendidikan Jasmani, memiliki persyaratan khusus seperti tidak buta warna yang harus dibuktikan dengan surat keterangan dari dokter spesialis atau pemerintah.</span></li></ul>',
grp_habisiniseminar_mandiri_mekanisme_full: '<p class="mek-text">Penerimaan mahasiswa baru Program Diploma dan Sarjana melalui jalur Seleksi Mandiri UNTAN didasarkan pada hasil Ujian Tulis Berbasis Komputer (UTBK) dan/atau tes tambahan sesuai dengan program studi yang dipilih. Berikut adalah rincian ujian dan tes tambahan yang dilakukan:</p><ul class="mek-check-list"><li class="req-list-item--nested"><span class="req-check"><i class="fas fa-check"></i></span><div class="req-list-content"><p class="req-list-text"><strong>Ujian Tulis Berbasis Komputer (UTBK)</strong> yang memuat materi berikut:</p><ul class="req-sublist"><li>Penalaran verbal</li><li>Kemampuan Kuantitatif</li><li>Pemecahan masalah</li><li>Literasi Bahasa Indonesia</li><li>Literasi Bahasa Inggris</li><li>Numerasi</li></ul></div></li><li class="req-list-item--nested"><span class="req-check"><i class="fas fa-check"></i></span><div class="req-list-content"><p class="req-list-text"><strong>Tes Tambahan:</strong></p><ul class="req-sublist"><li>Ujian keterampilan untuk Program Studi Pendidikan Seni Pertunjukan, Pendidikan Jasmani, dan Pendidikan Kepelatihan Olahraga (PKO).</li><li>Tes keterampilan Program Studi Pendidikan Seni Pertunjukan meliputi tes pengetahuan dan keterampilan bidang Ilmu Seni terkait.</li><li>Tes keterampilan Program Studi Pendidikan Jasmani dan Pendidikan Kepelatihan Olahraga (PKO) meliputi pemeriksaan kesehatan dan tes keterampilan motorik.</li><li>Tes psikologi dan wawancara khusus bagi calon mahasiswa Program Studi Kedokteran</li></ul></div></li></ul><p class="mek-text">Peserta yang <strong>tidak hadir</strong> pada saat UTBK dan/atau Tes Tambahan <strong>dianggap mengundurkan diri</strong> dari proses seleksi Jalur Mandiri UNTAN. Peserta jalur afirmasi dan/atau jalur prestasi dan penelusuran bakat dilakukan verifikasi melalui wawancara.</p>',
grp_habisiniseminar_tl_mandiri_1: 'Pendaftaran Seleksi Mandiri UNTAN',
grp_habisiniseminar_tl_mandiri_2: 'Pembuatan PIN',
grp_habisiniseminar_tl_mandiri_3: 'Pelaksanaan UTBK Jalur Seleksi Mandiri',
grp_habisiniseminar_tl_mandiri_4: 'Pelaksanaan Tes Tambahan',
grp_habisiniseminar_tl_mandiri_5: 'Wawancara Kategori Jalur Afirmasi, Prestasi, dan Penelusuran Bakat',
grp_habisiniseminar_tl_mandiri_6: 'Pengumuman Hasil Seleksi Mandiri',
grp_habisiniseminar_mandiri_beasiswa_desc: 'Mahasiswa jalur Mandiri UNTAN dapat mengakses berbagai program beasiswa yang tersedia sesuai kriteria dan mekanisme pengajuan masing-masing penyelenggara.',
// ── Shared Section Headings ────────────────────────────────────────────
grp_habisiniseminar_section_persyaratan: 'Persyaratan',
grp_habisiniseminar_section_mekanisme: 'Mekanisme',
grp_habisiniseminar_section_jadwal: 'Jadwal Pendaftaran',
grp_habisiniseminar_section_biaya: 'Biaya Pendaftaran & Pendidikan',
grp_habisiniseminar_section_beasiswa: 'Beasiswa',
// ── Charts ────────────────────────────────────────────────────────────
grp_habisiniseminar_chart_title: 'Grafik Peminat dan Daya Tampung per Tahun',
grp_habisiniseminar_chart_legend_applicants: 'Jumlah Peminat',
grp_habisiniseminar_chart_legend_capacity: 'Daya Tampung',
// ── Fees ──────────────────────────────────────────────────────────────
grp_habisiniseminar_acc_biaya_pendaftaran: 'Biaya Pendaftaran',
grp_habisiniseminar_acc_biaya_pendidikan: 'Biaya Pendidikan',
grp_habisiniseminar_acc_biaya_ipi: 'Iuran Pengembangan Institusi (IPI)',
grp_habisiniseminar_fee_selection: 'Pembayaran Seleksi',
grp_habisiniseminar_fee_snbt_utbk: 'Biaya UTBK',
grp_habisiniseminar_fee_smmptn: 'Biaya Ujian SMMPTN',
grp_habisiniseminar_fee_smmptn_payment_note: 'Biaya pendaftaran dibayarkan pada saat proses pendaftaran secara online melalui bank mitra yang telah ditunjuk yaitu Bank Mandiri, Bank BNI, Bank BTN, dan Bank BSI. Biaya pendaftaran yang telah dibayarkan <strong>TIDAK DAPAT</strong> ditarik kembali dengan alasan apa pun.',
grp_habisiniseminar_fee_mandiri_utbk: 'Biaya Ujian Tulis Berbasis Komputer (UTBK) Jalur Mandiri',
grp_habisiniseminar_fee_mandiri_keterampilan: 'Biaya Tambahan Ujian Keterampilan (Program Studi Pendidikan Seni Pertunjukan, Pendidikan Jasmani, dan Pendidikan Kepelatihan Olahraga (PKO))',
grp_habisiniseminar_fee_mandiri_wawancara: 'Biaya Tambahan Wawancara (Program Studi Kedokteran)',
grp_habisiniseminar_fee_mandiri_psikologi: 'Biaya Tambahan Tes Psikologi (Program Studi Kedokteran)',
grp_habisiniseminar_fee_mandiri_payment_note: 'Seluruh biaya dibayarkan melalui host to host atau teller Bank Kalbar. Penting untuk dicatat bahwa biaya yang sudah dibayarkan tidak dapat diubah atau ditarik kembali dengan alasan apapun.',
grp_habisiniseminar_fee_col_group: 'Golongan UKT',
grp_habisiniseminar_fee_col_cost: 'Biaya (Rp)',
grp_habisiniseminar_fee_col_ipi_group: 'Kelompok IPI',
grp_habisiniseminar_fee_desc_ukt: 'Uang Kuliah Tunggal (UKT) dikenakan kepada seluruh mahasiswa baru Program Diploma dan Sarjana dan <strong>dibayarkan setiap semester</strong> selama masa studi sesuai dengan kalender akademik Universitas Tanjungpura.',
grp_habisiniseminar_fee_highlight_ukt: 'Besaran UKT terbagi dalam beberapa kelompok yang ditentukan berdasarkan kemampuan ekonomi mahasiswa, orang tua, atau pihak lain yang membiayai mahasiswa.',
grp_habisiniseminar_fee_ipi_desc: 'Iuran Pengembangan Institusi (IPI) dikenakan khusus bagi mahasiswa yang <strong>diterima melalui jalur SMM PTN-Barat dan jalur Mandiri</strong>. Pembayaran IPI dilakukan satu kali selama masa studi dan dapat diangsur hingga <strong>empat semester</strong>.',
grp_habisiniseminar_fee_ipi_highlight: 'Mahasiswa yang diterima melalui jalur SNBP dan SNBT tidak dikenakan IPI.',
// ── Scholarships ──────────────────────────────────────────────────────
grp_habisiniseminar_scholar_kip: 'Kartu Indonesia Pintar Kuliah',
grp_habisiniseminar_scholar_kipk: 'KIP-Kuliah',
grp_habisiniseminar_scholar_bca: 'Beasiswa BCA',
grp_habisiniseminar_scholar_djarum: 'Djarum Beasiswa Plus',
grp_habisiniseminar_scholar_genbi: 'Beasiswa Bank Indonesia',
grp_habisiniseminar_scholar_adik: 'Beasiswa ADik Papua & 3T',
grp_habisiniseminar_scholar_kip_desc: 'KIP-Kuliah ditujukan secara eksklusif hanya untuk yang tidak mampu secara ekonomi',
grp_habisiniseminar_scholar_bca_desc: 'Beasiswa dalam bentuk Program Pendidikan Teknik Informatika (PPTI) yang diselenggarakan oleh BCA.',
grp_habisiniseminar_scholar_djarum_desc: 'Beasiswa pengembangan talenta digital dari Djarum Foundation untuk mahasiswa semester 4 ke atas.',
grp_habisiniseminar_scholar_genbi_desc: 'Beasiswa dari Bank Indonesia yang memberikan bantuan pendidikan dan pengembangan diri bagi mahasiswa berprestasi.',
grp_habisiniseminar_scholar_adik_desc: 'Beasiswa afirmasi untuk mahasiswa asli Papua, Papua Barat, dan daerah 3T (Terdepan, Terluar, Tertinggal).',
grp_habisiniseminar_scholar_kipk_desc: 'Program pemerintah untuk mahasiswa tidak mampu secara ekonomi namun berprestasi.',
grp_habisiniseminar_scholar_bca_snbt_desc: 'Program Pendidikan Teknik Informatika (PPTI) yang diselenggarakan oleh BCA untuk mahasiswa terpilih.',
grp_habisiniseminar_scholar_unggulan_desc: 'Beasiswa dari Kemendikbud untuk mahasiswa berprestasi di tingkat nasional maupun internasional.',
grp_habisiniseminar_scholar_untan_smmptn_desc: 'Diberikan kepada mahasiswa baru dengan nilai seleksi tertinggi dari jalur SMMPTN-Barat.',
grp_habisiniseminar_scholar_bca_mandiri_desc: 'Program Pendidikan Teknik Informatika (PPTI) dari BCA untuk mahasiswa dengan prestasi akademik unggul.',
grp_habisiniseminar_scholar_untan_mandiri_desc: 'Beasiswa dari UNTAN untuk mahasiswa baru dengan nilai seleksi tertinggi pada jalur Mandiri.',
grp_habisiniseminar_btn_detail: 'Lihat Detail',
// ── FAQ ───────────────────────────────────────────────────────────────
grp_habisiniseminar_faq_title: 'Pertanyaan Umum tentang Penerimaan Mahasiswa Baru',
grp_habisiniseminar_faq_sub: 'Temukan jawaban atas pertanyaan Anda',
grp_habisiniseminar_faq_q1: 'Bagaimana cara mengetahui hasil seleksi?',
grp_habisiniseminar_faq_a1: 'Hasil seleksi diumumkan secara online melalui portal resmi sesuai jadwal yang telah ditentukan.',
grp_habisiniseminar_faq_q2: 'Apa konsekuensi jika tidak melakukan registrasi ulang atau pembayaran tepat waktu?',
grp_habisiniseminar_faq_a2: 'Keterlambatan registrasi atau pembayaran dapat menyebabkan status kelulusan dibatalkan atau dianggap mengundurkan diri.',
grp_habisiniseminar_faq_q3: 'Apa yang harus dilakukan setelah dinyatakan lulus seleksi?',
grp_habisiniseminar_faq_a3: 'Calon mahasiswa wajib melakukan registrasi ulang dan memenuhi seluruh persyaratan administrasi sesuai ketentuan. Informasi lebih lanjut dapat diakses melalui website atau media sosial resmi Informatika UNTAN.',
grp_habisiniseminar_faq_q4: 'Berapa pilihan peminatan yang tersedia pada program studi ini?',
grp_habisiniseminar_faq_a4: 'Program studi ini menyediakan 4 peminatan, yaitu Kecerdasan Buatan (AI), Rekayasa Perangkat Lunak, Jaringan & Keamanan, serta Sistem Informasi.',
grp_habisiniseminar_faq_q5: 'Apakah mahasiswa akan mendapatkan pengalaman praktik atau proyek selama kuliah?',
grp_habisiniseminar_faq_a5: 'Ya, mahasiswa akan mengikuti berbagai praktikum, tugas proyek, serta kerja kelompok yang dirancang untuk meningkatkan keterampilan teknis dan pemecahan masalah secara nyata.',
grp_habisiniseminar_faq_q6: 'Bagaimana suasana dan kehidupan kampus di Informatika UNTAN?',
grp_habisiniseminar_faq_a6: 'Lingkungan kampus mendukung kegiatan akademik dan non-akademik, dengan berbagai organisasi mahasiswa dan komunitas teknologi. Untuk mengetahui lebih lanjut mengenai kehidupan kampus dan aktivitas mahasiswa, silakan kunjungi halaman terkait <a href="https://informatika.untan.ac.id/content.html?type=people_stories" target="_blank" rel="noopener noreferrer">di sini</a>.',
grp_habisiniseminar_faq_q7: 'Apakah lulusan Informatika UNTAN memiliki peluang kerja yang baik?',
grp_habisiniseminar_faq_a7: 'Lulusan memiliki peluang kerja yang luas di berbagai bidang seperti pengembangan perangkat lunak, analisis data, keamanan jaringan, hingga kewirausahaan di bidang teknologi.',
grp_habisiniseminar_faq_q8: 'Apakah mahasiswa Informatika UNTAN dapat mengikuti lomba atau kompetisi?',
grp_habisiniseminar_faq_a8: 'Ya, mahasiswa didorong untuk berpartisipasi dalam berbagai lomba dan kompetisi di bidang teknologi, baik tingkat lokal, nasional, maupun internasional. Informasi mengenai lomba dan prestasi mahasiswa dapat diakses <a href="https://informatika.untan.ac.id/content.html?type=people_stories" target="_blank" rel="noopener noreferrer">di sini</a>.',
// ── Months ────────────────────────────────────────────────────────────
grp_habisiniseminar_month_jan: 'Januari',
grp_habisiniseminar_month_feb: 'Februari',
grp_habisiniseminar_month_mar: 'Maret',
grp_habisiniseminar_month_apr: 'April',
grp_habisiniseminar_month_mei: 'Mei',
grp_habisiniseminar_month_jun: 'Juni',
grp_habisiniseminar_month_jul: 'Juli',
grp_habisiniseminar_month_des: 'Desember',
grp_habisiniseminar_month_des_jan: 'Desember Januari',
grp_habisiniseminar_month_jan_feb: 'Januari Februari',
grp_habisiniseminar_month_jan_apr: 'Januari April',
grp_habisiniseminar_month_feb_apr: 'Februari April',
grp_habisiniseminar_month_feb_mar: 'Februari Maret',
grp_habisiniseminar_month_mar_apr: 'Maret April',
grp_habisiniseminar_month_mar_mei: 'Mar Mei',
grp_habisiniseminar_month_apr_mei: 'April Mei',
grp_habisiniseminar_month_mei_jun: 'Mei Juni',
grp_habisiniseminar_month_jun_jul: 'Juni Juli',
},
en: {
@@ -1135,6 +1342,213 @@ const translations = {
kontrib_guideline_3: "Include accurate and verifiable facts and data.",
kontrib_guideline_4: "Avoid plagiarism. Work must be original.",
kontrib_guideline_5: "The editorial team reserves the right to edit content for editorial purposes.",
// --- apeNameTeamE (Scholarship Page) ---
ape_breadcrumb_profile: 'Profile',
ape_breadcrumb_scholarship: 'Scholarship',
ape_hero_badge: 'Scholarship Program',
ape_hero_title: 'Study &amp; Research<br>Scholarships',
ape_hero_subtitle: 'Informatics Engineering, Tanjungpura University',
ape_hero_desc: 'Discover various scholarship opportunities to support your academic and research journey. The Informatics Study Program at UNTAN is committed to helping students achieve excellence.',
ape_hero_cta: 'View Scholarships',
ape_stats_total: 'Total Scholarship<br>Recipients This Semester',
ape_stats_s1: 'Total Undergraduate<br>Scholarship Recipients',
ape_stats_studi: 'Total Postgraduate<br>Scholarship Recipients',
ape_dir_label: 'Scholarship Directory',
ape_dir_title: 'Scholarship Directory',
ape_dir_desc: 'Find various scholarships from official sources available for active students, alumni, and prospective students of the UNTAN Informatics Study Program.',
ape_dir_guide: 'Need Scholarship Aid?',
ape_filter_s1: 'Undergraduate',
ape_filter_studi: 'Postgraduate',
ape_search_placeholder: 'Search Scholarship Name...',
ape_testimonial_title: 'What They Say?',
ape_testimonial_desc: 'Inspiring stories from scholarship recipients at UNTAN Informatics Study Program',
ape_cta_title: 'Want to Know More?',
ape_cta_desc: 'Contact the program admin for the latest scholarship and academic program information.',
ape_cta_btn1: 'SCHOLARSHIP LIST',
ape_cta_btn2: 'Contact Us',
// ── Page ──────────────────────────────────────────────────────────────
grp_habisiniseminar_page_title: 'New Student Admission Informatics UNTAN | Group 02',
grp_habisiniseminar_brand: 'Informatics UNTAN New Student Admission',
grp_habisiniseminar_badge: 'Static Page',
grp_habisiniseminar_hero_title_1: 'New Student Admission',
grp_habisiniseminar_hero_title_2: 'Informatics UNTAN',
grp_habisiniseminar_hero_description: 'Information and schedule for new student registration via the SNBP, SNBT, and UNTAN Independent Pathways',
grp_habisiniseminar_meta_group: 'Group 02',
grp_habisiniseminar_about_title: 'Undergraduate Program Selection',
grp_habisiniseminar_about_desc: 'The Informatics Study Program at Tanjungpura University opens opportunities for the nation\'s best students to join through various selection pathways. We seek talented individuals ready to become future technology innovators.',
// ── Pathways ─────────────────────────────────────────────────────────
grp_habisiniseminar_path_snbp_desc: 'National Selection Based on Achievement',
grp_habisiniseminar_path_snbt_desc: 'National Selection Based on Test',
grp_habisiniseminar_path_smmptn_desc: 'Western Region Joint Independent Selection',
grp_habisiniseminar_path_mandiri_desc: 'Tanjungpura University Independent Selection',
grp_habisiniseminar_info_jalur_title: 'Selection Pathway Information',
// ── Sidebar ──────────────────────────────────────────────────────────
grp_habisiniseminar_sidebar_heading: 'Choose Pathway',
grp_habisiniseminar_sidebar_data_date: '*Data used as of May 2026',
grp_habisiniseminar_sidebar_link_general: 'General Information',
grp_habisiniseminar_sidebar_link_persyaratan: 'Requirements',
grp_habisiniseminar_sidebar_link_mekanisme: 'Process',
grp_habisiniseminar_sidebar_link_jadwal: 'Schedule',
grp_habisiniseminar_sidebar_link_biaya: 'Registration & Education Fees',
grp_habisiniseminar_sidebar_link_beasiswa: 'Scholarships',
// ── SNBP ──────────────────────────────────────────────────────────────
grp_habisiniseminar_snbp_title: 'SNBP National Selection Based on Achievement',
grp_habisiniseminar_info_snbp: 'The National Selection Based on Achievement (SNBP) is a state university (PTN) admission pathway organized nationally by SNPMB. SNBP uses a selection mechanism based on secondary-school learning outcomes, including grade reports, academic and non-academic achievements, and additional criteria set by each PTN. This scheme aims to give opportunities to students with outstanding and consistent achievement records. SNBP is an integral part of the government\'s effort to create an objective, transparent selection process that strengthens school-level learning.',
grp_habisiniseminar_acc_snbp_req_general: 'General Requirements to Register for SNBP',
grp_habisiniseminar_acc_snbp_ketentuan: 'SNPMB Regulations',
grp_habisiniseminar_acc_snbp_req_accepted: 'General Requirements to be Accepted via SNBP',
grp_habisiniseminar_snbp_req_general_body: '<ul class="req-list"><li><span class="req-check"><i class="fas fa-check"></i></span><span>Must be a final-year student of SMA/SMK/MA in the <strong>current period</strong> with <strong>outstanding achievement</strong>.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Must possess a NISN and be <strong>registered in PDSS</strong>.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Must have <strong>grade report scores</strong> entered in PDSS in accordance with the applicable requirements.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Must have demonstrable <strong>academic achievements</strong>.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Must meet additional requirements set by each Academic PTN and Vocational PTN.</span></li></ul>',
grp_habisiniseminar_snbp_ketentuan_body: '<ul class="req-list"><li><span class="req-check"><i class="fas fa-check"></i></span><span>Students who have been accepted through the SNBP pathway are not eligible to register for UTBK-SNBT.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Students accepted through SNBP may not apply for any Independent (Mandiri) pathway selection at any PTN during the same period.</span></li></ul>',
grp_habisiniseminar_snbp_req_accepted_body: '<ul class="req-list"><li><span class="req-check"><i class="fas fa-check"></i></span><span>Applicant has graduated from SMA/SMK/MA or equivalent secondary education</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Applicant has been declared accepted through SNBP in the current period</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Applicant has passed data verification and meets any additional requirements set by each PTN</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Applicant completes re-registration at the accepting PTN in accordance with applicable regulations</span></li></ul>',
grp_habisiniseminar_snbp_mekanisme_text: '<p class="mek-text">The National Selection Based on Achievement (SNBP) at Tanjungpura University evaluates students\' overall academic performance through grade reports, along with achievements that add value to the selection. Evaluated components include:</p><ul class="mek-check-list"><li><span class="req-check" style="margin-top: 2px"><i class="fas fa-check"></i></span><span><strong>Grade reports:</strong> the average score across all subjects, including those relevant to the chosen study program.</span></li><li><span class="req-check" style="margin-top: 2px"><i class="fas fa-check"></i></span><span><strong>Academic and non-academic achievements:</strong> accomplishments in academic competitions (e.g., olympiads or science contests) as well as non-academic fields (e.g., sports, arts, organizations, and extracurricular activities).</span></li></ul>',
grp_habisiniseminar_tl_snbp_1: 'School quota announcement and appeal period',
grp_habisiniseminar_tl_snbp_2: 'Account registration and PDSS data entry by schools',
grp_habisiniseminar_tl_snbp_3: 'Student SNPMB account registration',
grp_habisiniseminar_tl_snbp_4: 'SNBP registration',
grp_habisiniseminar_tl_snbp_5: 'SNBP results announcement',
grp_habisiniseminar_tl_snbp_6: 'Download SNBP participant card',
grp_habisiniseminar_snbp_fee_desc: 'UKT is divided into several categories adjusted to the student\'s family economic capacity and is <strong>paid every semester</strong>.',
grp_habisiniseminar_fee_highlight_snbp: 'Students admitted through SNBP using KIP-Kuliah will be verified and may be eligible for UKT exemption in accordance with KIP-Kuliah and UNTAN regulations.',
grp_habisiniseminar_snbp_beasiswa_desc: 'Students may obtain scholarships from various sources, with application mechanisms according to the rules of each scholarship provider.',
// ── SNBT ──────────────────────────────────────────────────────────────
grp_habisiniseminar_snbt_title: 'SNBT National Selection Based on Test',
grp_habisiniseminar_info_snbt: 'The National Selection Based on Test (SNBT) is a PTN admission pathway that uses a nationally standardized examination. Participants take a series of tests measuring academic potential, reasoning ability, and readiness for higher education. SNBT assessment is based entirely on test scores — admission is not influenced by economic background, school type (public or private), or regional location. All participants are evaluated on the same standard, ensuring a fair and transparent process. SNBT is one of SNPMB\'s primary instruments for guaranteeing meritocratic, accountable, and inclusive access to higher education.',
grp_habisiniseminar_acc_snbt_req_general: 'General Requirements to Register for SNBT',
grp_habisiniseminar_acc_snbt_utbk: 'SNPMB Regulations',
grp_habisiniseminar_acc_snbt_req_prodi: 'General Requirements to be Accepted via SNBT',
grp_habisiniseminar_snbt_req_general_body: '<ul class="req-list"><li><span class="req-check"><i class="fas fa-check"></i></span><span>Applicants must have a <strong>Student SNPMB Account</strong>. Registration can be done on the SNPMB Portal.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Applicants must be <strong>Indonesian citizens (WNI)</strong> with a valid <strong>National Identity Number (NIK)</strong>.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Applicants must be final-year students of SMA/SMK/MA or equivalent, or Paket C students aged <strong>no more than 25 years</strong>.</span></li><li class="req-list-item--nested"><span class="req-check"><i class="fas fa-check"></i></span><div class="req-list-content"><p class="req-list-text">Applicants who <strong>have not yet received their diploma</strong> must submit a <strong>Grade 12 student certificate</strong> containing at minimum:</p><ul class="req-sublist"><li>Personal identity (name, class, NISN, and NPSN)</li><li>Most recent <strong>colour</strong> photograph</li><li>Signature of the School/Madrasah Principal</li><li>Official school stamp/seal</li></ul></div></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Overseas graduates of SMA or equivalent must hold a recognized equivalent certificate.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Applicants choosing study programs in the fields of <strong>Arts and/or Sports</strong> must upload a portfolio.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Applicants must be in a health condition that does not interfere with the study process.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Applicants with visual impairment (<strong>tunanetra</strong>) must upload a <strong>Visual Impairment Declaration Letter</strong>.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Applicants must pay the UTBK fee as required.</span></li></ul>',
grp_habisiniseminar_snbt_ketentuan_body: '<ul class="req-list"><li><span class="req-check"><i class="fas fa-check"></i></span><span>Applicants must have a Student SNPMB Account. Registration can be done on the SNPMB Portal.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Applicants must be Indonesian citizens (WNI) with a valid National Identity Number (NIK).</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Applicants must be final-year SMA/SMK/MA students or equivalent, or 2026 Paket C students aged no more than 25 years.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Applicants choosing Arts and/or Sports study programs must upload a portfolio.</span></li></ul>',
grp_habisiniseminar_snbt_req_accepted_body: '<ul class="req-list"><li><span class="req-check"><i class="fas fa-check"></i></span><span>Applicant has graduated from SMA/SMK/MA or equivalent secondary education.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Applicant has been officially accepted through SNBT selection.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Applicant has passed data verification and meets any additional requirements set by each PTN.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Applicant completes re-registration at the accepting PTN in accordance with applicable regulations.</span></li></ul>',
grp_habisiniseminar_snbt_mekanisme_full: '<p class="mek-text">The National Selection Based on Test (SNBT) is conducted through the Computer-Based Written Examination (UTBK), a standardized test measuring cognitive potential, mathematical reasoning, and literacy skills in both Indonesian and English. UTBK aims to assess logical thinking, problem-solving, and high learning readiness so that students can handle the material taught in specific study programs. Equal opportunities are also created for all applicants regardless of their economic background or the quality of their previous school.</p><ul class="mek-check-list"><li class="req-list-item--nested"><span class="req-check"><i class="fas fa-check"></i></span><div class="req-list-content"><p class="req-list-text"><strong>Scholastic Potential Test (TPS)</strong> measures the cognitive abilities required for success in formal higher education. Tested aspects include:</p><ul class="req-sublist"><li><strong>General reasoning ability</strong>, covering inductive, deductive, and quantitative reasoning</li><li>General knowledge and understanding</li><li>Reading comprehension and writing</li><li><strong>Quantitative Knowledge</strong>, covering basic mathematics knowledge and mastery</li></ul></div></li><li class="req-list-item--nested"><span class="req-check"><i class="fas fa-check"></i></span><div class="req-list-content"><p class="req-list-text"><strong>Literacy Test</strong> measures applicants\' ability to think using mathematical concepts, procedures, facts, and tools to solve everyday problems. Tested materials include:</p><ul class="req-sublist"><li>Literacy in <strong>Indonesian</strong></li><li>Literacy in <strong>English</strong></li><li><strong>Mathematical Reasoning</strong></li></ul></div></li></ul><p class="mek-text">The mathematical reasoning section assesses applicants\' ability to apply mathematical concepts and procedures to solve contextual problems, and to evaluate and reflect on information so it can be used effectively in daily life. SNBT test instruments are developed by education assessment experts, taking into account technical aspects such as time allocation and difficulty level, so that results objectively and measurably reflect each applicant\'s true ability.</p>',
grp_habisiniseminar_tl_snbt_1: 'Student SNPMB Account Registration',
grp_habisiniseminar_tl_snbt_2: 'SNBT Registration',
grp_habisiniseminar_tl_snbt_3: 'UTBK Fee Payment',
grp_habisiniseminar_tl_snbt_4: 'UTBK Examination',
grp_habisiniseminar_tl_snbt_5: 'SNBT Results Announcement',
grp_habisiniseminar_tl_snbt_6: 'UTBK Certificate Download Period',
grp_habisiniseminar_fee_highlight_snbt: 'Students admitted through SNBT using KIP-Kuliah will be verified and may be eligible for UKT exemption in accordance with KIP-Kuliah and UNTAN regulations.',
grp_habisiniseminar_snbt_beasiswa_desc: 'SNBT students are also eligible to apply for various scholarship programs according to each provider\'s terms and conditions.',
// ── SMMPTN-Barat ───────────────────────────────────────────────────────
grp_habisiniseminar_smmptn_title: 'SMMPTN West Western Region Joint Independent Selection',
grp_habisiniseminar_info_smmptn: 'The Independent Selection for State Universities in the Western Region (SMMPTN-Barat) is an independent selection process held jointly by several state universities in the western region of Indonesia, utilizing computer-based exams as the basis for assessment. This process provides additional opportunities for high school graduates who have not been accepted through the National Selection Based on Achievement (SNBP) or National Selection Based on Tests (SNBT), either due to limited quotas or inappropriate selection of study programs and state universities. Furthermore, SMMPTN-Barat also serves as an alternative for participants who do not yet have an SNPMB account to still participate in the state university entrance selection process.',
grp_habisiniseminar_smmptn_persyaratan_list: '<ul class="req-list"><li><span class="req-check"><i class="fas fa-check"></i></span><span>Applicants must be graduates of SMA/SMK/MA or equivalent within the <strong>last 3 years</strong>, or hold a Paket C certificate with a maximum age of <strong>25 years</strong>.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Applicants may select up to <strong>2 (two)</strong> study programs at one PTN or at two different PTNs — not necessarily located at the UTBK test centre — with the order of choices indicating priority.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>The examination is administered as a <strong>Computer-Based Written Test (UTBK)</strong>.</span></li></ul>',
grp_habisiniseminar_smmptn_mekanisme_full: '<p class="mek-text">The SMMPTN-West selection mechanism is carried out through a computer-based written examination designed to assess the academic ability and higher-education readiness of applicants. The exam uses a standardized testing approach focused on cognitive ability, literacy, and reasoning, ensuring that applicants have critical thinking skills, problem-solving ability, and strong learning capacity. The main types of material covered in the exam are as follows:</p><ul class="mek-check-list"><li class="req-list-item--nested"><span class="req-check"><i class="fas fa-check"></i></span><div class="req-list-content"><p class="req-list-text"><strong>Scholastic Potential Test (TPS)</strong> measures the cognitive abilities needed to succeed in formal higher education. Tested aspects include:</p><ul class="req-sublist"><li>General Reasoning Ability</li><li>Quantitative Ability covering knowledge and mastery of basic mathematics</li><li>General Knowledge and Understanding</li><li>Reading Comprehension and Writing Ability</li></ul></div></li><li><span class="req-check" style="margin-top: 2px"><i class="fas fa-check"></i></span><span><strong>Literacy Test</strong> aims to measure applicants\' ability to understand, use, evaluate, and reflect on various types of texts in order to solve problems and develop individual capacity as citizens of Indonesia and of the world. The test covers literacy in Indonesian and literacy in English.</span></li><li><span class="req-check" style="margin-top: 2px"><i class="fas fa-check"></i></span><span><strong>Mathematical Reasoning</strong> measures the ability to think using mathematical concepts, procedures, facts, and tools to solve everyday problems across various types of contexts relevant to individuals as citizens of Indonesia and of the world.</span></li></ul>',
grp_habisiniseminar_tl_smmptn_1: 'Online registration',
grp_habisiniseminar_tl_smmptn_2: 'SMMPTN UTBK Examination',
grp_habisiniseminar_tl_smmptn_3: 'SMMPTN Selection Results Announcement',
grp_habisiniseminar_smmptn_beasiswa_desc: 'Various scholarships are available to students admitted via the SMMPTN-West pathway and may be applied for after the admission process is complete.',
// ── Mandiri UNTAN ──────────────────────────────────────────────────────
grp_habisiniseminar_mandiri_title: 'Mandiri UNTAN Tanjungpura University Independent Selection',
grp_habisiniseminar_info_mandiri: 'UNTAN Mandiri Selection is a new student admission pathway organized directly by Tanjungpura University. This pathway serves as an alternative for prospective students wishing to study at UNTAN through the selection mechanism established by the university in accordance with applicable regulations.',
grp_habisiniseminar_mandiri_persyaratan_list: '<ul class="req-list"><li><span class="req-check"><i class="fas fa-check"></i></span><span>Applicants must be graduates of SMA/SMK/MA or equivalent within the <strong>last 3 years</strong>, or hold a Paket C certificate with a maximum age of 25 years.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span><strong>Morning and International classes</strong> are open to graduates from the last three years.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span><strong>Afternoon and Evening classes</strong> have no graduation year restriction for Paket C graduates.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Applicants must be in adequate health to participate in the university learning process.</span></li><li><span class="req-check"><i class="fas fa-check"></i></span><span>Certain study programs, such as Medicine and Physical Education, have specific requirements (e.g., no colour blindness) that must be certified by a specialist doctor or government authority.</span></li></ul>',
grp_habisiniseminar_mandiri_mekanisme_full: '<p class="mek-text">Admission for Diploma and Bachelor\'s programs through the UNTAN Mandiri Selection pathway is based on Computer-Based Written Examination (UTBK) results and/or additional tests according to the chosen study program. Below are the details of the examinations and additional tests conducted:</p><ul class="mek-check-list"><li class="req-list-item--nested"><span class="req-check"><i class="fas fa-check"></i></span><div class="req-list-content"><p class="req-list-text"><strong>Computer-Based Written Examination (UTBK)</strong> covering the following material:</p><ul class="req-sublist"><li>Verbal reasoning</li><li>Quantitative ability</li><li>Problem solving</li><li>Indonesian literacy</li><li>English literacy</li><li>Numeracy</li></ul></div></li><li class="req-list-item--nested"><span class="req-check"><i class="fas fa-check"></i></span><div class="req-list-content"><p class="req-list-text"><strong>Additional Tests:</strong></p><ul class="req-sublist"><li>Skills examination for the Performing Arts Education, Physical Education, and Sports Coaching Education (PKO) study programs.</li><li>The Performing Arts Education skills test covers knowledge and skill assessments in the relevant art field.</li><li>The Physical Education and Sports Coaching Education (PKO) skills test includes a medical examination and motor skill assessment.</li><li>Psychological test and interview for applicants to the Medicine study program.</li></ul></div></li></ul><p class="mek-text">Applicants who are <strong>absent</strong> during the UTBK and/or Additional Tests are considered to have <strong>withdrawn</strong> from the UNTAN Mandiri Selection process. Applicants under the affirmative action, achievement, or talent scouting pathways will be verified through an interview.</p>',
grp_habisiniseminar_tl_mandiri_1: 'UNTAN Mandiri Selection Registration',
grp_habisiniseminar_tl_mandiri_2: 'PIN Generation',
grp_habisiniseminar_tl_mandiri_3: 'UTBK Examination — Mandiri Selection Pathway',
grp_habisiniseminar_tl_mandiri_4: 'Additional Tests',
grp_habisiniseminar_tl_mandiri_5: 'Interviews for Affirmative Action, Achievement, and Talent Scouting Pathways',
grp_habisiniseminar_tl_mandiri_6: 'Mandiri Selection Results Announcement',
grp_habisiniseminar_mandiri_beasiswa_desc: 'Mandiri pathway students at UNTAN can access various scholarship programs according to the eligibility criteria and application mechanisms of each provider.',
// ── Shared Section Headings ────────────────────────────────────────────
grp_habisiniseminar_section_persyaratan: 'Requirements',
grp_habisiniseminar_section_mekanisme: 'Admission Process',
grp_habisiniseminar_section_jadwal: 'Registration Schedule',
grp_habisiniseminar_section_biaya: 'Registration & Education Fees',
grp_habisiniseminar_section_beasiswa: 'Scholarships',
// ── Charts ────────────────────────────────────────────────────────────
grp_habisiniseminar_chart_title: 'Applicants and Capacity per Year',
grp_habisiniseminar_chart_legend_applicants: 'Number of Applicants',
grp_habisiniseminar_chart_legend_capacity: 'Capacity',
// ── Fees ──────────────────────────────────────────────────────────────
grp_habisiniseminar_acc_biaya_pendaftaran: 'Registration Fees',
grp_habisiniseminar_acc_biaya_pendidikan: 'Education Fees',
grp_habisiniseminar_acc_biaya_ipi: 'Institutional Development Fee (IPI)',
grp_habisiniseminar_fee_selection: 'Selection Fee',
grp_habisiniseminar_fee_snbt_utbk: 'UTBK Fee',
grp_habisiniseminar_fee_smmptn: 'SMMPTN Examination Fee',
grp_habisiniseminar_fee_smmptn_payment_note: 'Registration fees are paid during the online registration process through designated partner banks: Bank Mandiri, Bank BNI, Bank BTN, and Bank BSI. Registration fees that have been paid <strong>CANNOT</strong> be refunded under any circumstances.',
grp_habisiniseminar_fee_mandiri_utbk: 'Computer-Based Written Test (UTBK) Fee — Mandiri Pathway',
grp_habisiniseminar_fee_mandiri_keterampilan: 'Additional Skill Test Fee (Performing Arts Education, Physical Education, and Sports Coaching Education (PKO) study programs)',
grp_habisiniseminar_fee_mandiri_wawancara: 'Additional Interview Fee (Medicine Study Program)',
grp_habisiniseminar_fee_mandiri_psikologi: 'Additional Psychological Test Fee (Medicine Study Program)',
grp_habisiniseminar_fee_mandiri_payment_note: 'All fees are paid through Bank Kalbar host-to-host transfer or teller services. Please note that fees already paid cannot be modified or refunded for any reason.',
grp_habisiniseminar_fee_col_group: 'UKT Category',
grp_habisiniseminar_fee_col_cost: 'Cost (IDR)',
grp_habisiniseminar_fee_col_ipi_group: 'IPI Group',
grp_habisiniseminar_fee_desc_ukt: 'Single Tuition Fee (UKT) is charged to all new Diploma and Bachelor\'s program students and is <strong>paid every semester</strong> throughout the study period in accordance with the Tanjungpura University academic calendar.',
grp_habisiniseminar_fee_highlight_ukt: 'UKT amounts are divided into several groups determined by the economic capacity of the student, parents, or other parties funding the student\'s education.',
grp_habisiniseminar_fee_ipi_desc: 'The Institutional Development Fee (IPI) applies exclusively to students <strong>admitted through the SMMPTN-West and Mandiri pathways</strong>. IPI is paid once during the entire study period and may be paid in installments over up to <strong>four semesters</strong>.',
grp_habisiniseminar_fee_ipi_highlight: 'Students admitted through the SNBP and SNBT pathways are exempt from the IPI.',
// ── Scholarships ──────────────────────────────────────────────────────
grp_habisiniseminar_scholar_kip: 'Indonesia Smart Card for College',
grp_habisiniseminar_scholar_kipk: 'KIP-Kuliah',
grp_habisiniseminar_scholar_bca: 'BCA Scholarship',
grp_habisiniseminar_scholar_djarum: 'Djarum Plus Scholarship',
grp_habisiniseminar_scholar_genbi: 'Bank Indonesia Scholarship',
grp_habisiniseminar_scholar_adik: 'ADik Scholarship for Papua & 3T',
grp_habisiniseminar_scholar_kip_desc: 'KIP-Kuliah is exclusively aimed at students who are economically disadvantaged.',
grp_habisiniseminar_scholar_bca_desc: 'A scholarship in the form of the Informatics Engineering Education Program (PPTI) organized by BCA.',
grp_habisiniseminar_scholar_djarum_desc: 'Digital talent development scholarship from Djarum Foundation for 4th-semester students and above.',
grp_habisiniseminar_scholar_genbi_desc: 'A scholarship from Bank Indonesia that provides educational support and self-development opportunities for outstanding students.',
grp_habisiniseminar_scholar_adik_desc: 'Affirmation scholarship for students from Papua, West Papua, and underdeveloped, frontier, and outermost regions.',
grp_habisiniseminar_scholar_kipk_desc: 'A government program for economically disadvantaged yet high-achieving students.',
grp_habisiniseminar_scholar_bca_snbt_desc: 'The Informatics Engineering Education Program (PPTI) organized by BCA for selected students.',
grp_habisiniseminar_scholar_unggulan_desc: 'A scholarship from the Ministry of Education for students with national or international-level achievements.',
grp_habisiniseminar_scholar_untan_smmptn_desc: 'Awarded to new students with the highest selection scores from the SMMPTN-West pathway.',
grp_habisiniseminar_scholar_bca_mandiri_desc: 'BCA\'s Informatics Engineering Education Program (PPTI) for students with outstanding academic achievement.',
grp_habisiniseminar_scholar_untan_mandiri_desc: 'A scholarship from UNTAN for new students achieving the highest selection scores via the Mandiri pathway.',
grp_habisiniseminar_btn_detail: 'View Details',
// ── FAQ ───────────────────────────────────────────────────────────────
grp_habisiniseminar_faq_title: 'Frequently Asked Questions — New Student Admission',
grp_habisiniseminar_faq_sub: 'Find answers to your questions',
grp_habisiniseminar_faq_q1: 'How can I check my selection results?',
grp_habisiniseminar_faq_a1: 'Selection results are announced online through the official portal according to the scheduled dates.',
grp_habisiniseminar_faq_q2: 'What happens if I miss the re-registration or payment deadline?',
grp_habisiniseminar_faq_a2: 'Failure to re-register or pay on time may result in your admission status being revoked or treated as a voluntary withdrawal.',
grp_habisiniseminar_faq_q3: 'What should I do after being declared accepted?',
grp_habisiniseminar_faq_a3: 'Accepted applicants must complete re-registration and fulfill all administrative requirements. Further information is available on the official Informatics UNTAN website and social media channels.',
grp_habisiniseminar_faq_q4: 'How many specializations does this study program offer?',
grp_habisiniseminar_faq_a4: 'This program offers four specializations: Artificial Intelligence (AI), Software Engineering, Networks & Security, and Information Systems.',
grp_habisiniseminar_faq_q5: 'Will students gain practical or project experience during their studies?',
grp_habisiniseminar_faq_a5: 'Yes. Students will take part in various practicum sessions, project assignments, and group work designed to build technical skills and real-world problem-solving abilities.',
grp_habisiniseminar_faq_q6: 'What is campus life like at Informatics UNTAN?',
grp_habisiniseminar_faq_a6: 'The campus environment supports both academic and non-academic activities, with numerous student organizations and technology communities. To learn more about campus life and student activities, please visit the related page <a href="https://informatika.untan.ac.id/content.html?type=people_stories" target="_blank" rel="noopener noreferrer">here</a>.',
grp_habisiniseminar_faq_q7: 'Do Informatics UNTAN graduates have good career prospects?',
grp_habisiniseminar_faq_a7: 'Graduates have broad career opportunities across fields such as software development, data analytics, network security, and technology entrepreneurship.',
grp_habisiniseminar_faq_q8: 'Can Informatics UNTAN students participate in competitions?',
grp_habisiniseminar_faq_a8: 'Yes. Students are actively encouraged to participate in technology competitions at local, national, and international levels. Information on competitions and student achievements is available <a href="https://informatika.untan.ac.id/content.html?type=people_stories" target="_blank" rel="noopener noreferrer">here</a>.',
// ── Months ────────────────────────────────────────────────────────────
grp_habisiniseminar_month_jan: 'January',
grp_habisiniseminar_month_feb: 'February',
grp_habisiniseminar_month_mar: 'March',
grp_habisiniseminar_month_apr: 'April',
grp_habisiniseminar_month_mei: 'May',
grp_habisiniseminar_month_jun: 'June',
grp_habisiniseminar_month_jul: 'July',
grp_habisiniseminar_month_des: 'December',
grp_habisiniseminar_month_des_jan: 'December January',
grp_habisiniseminar_month_jan_feb: 'January February',
grp_habisiniseminar_month_jan_apr: 'January April',
grp_habisiniseminar_month_feb_apr: 'February April',
grp_habisiniseminar_month_feb_mar: 'February March',
grp_habisiniseminar_month_mar_apr: 'March April',
grp_habisiniseminar_month_mar_mei: 'Mar May',
grp_habisiniseminar_month_apr_mei: 'April May',
grp_habisiniseminar_month_mei_jun: 'May June',
grp_habisiniseminar_month_jun_jul: 'June July',
}
};
+1 -1
View File
@@ -997,7 +997,7 @@
<!-- Overlay for mega menu -->
<div id="mega-overlay" class="fixed inset-0 bg-black/30 backdrop-blur-sm z-30"></div>
</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"
+273
View File
@@ -0,0 +1,273 @@
# PROJECT CONTEXT
## Website Statis Halaman Beasiswa — Informatika UNTAN
> File ini adalah konteks utama proyek untuk AI assistant (Antigravity).
> Baca file ini setiap kali kamu membantu pengerjaan proyek ini.
---
## 🎯 Apa yang Dibangun?
**Single static landing page** berbasis HTML/CSS/JS murni (native web) untuk menampilkan informasi beasiswa bagi mahasiswa aktif, alumni, dan masyarakat umum Program Studi Informatika, Universitas Tanjungpura (UNTAN), Pontianak.
Website ini bukan aplikasi web dinamis. Tidak ada backend, tidak ada database, tidak ada login. Update konten dilakukan dengan mengedit file JSON dan push ke GitHub → auto-deploy via Puify ke server prodi.
---
## 🏗️ Tech Stack
| Layer | Teknologi |
|---|---|
| Markup | HTML5 |
| Styling | CSS3 + Tailwind CSS |
| Interaktivitas | Vanilla JavaScript (ES6+) |
| Font | Google Fonts (Inter / Poppins) |
| Ikon | Font Awesome |
| Animasi | AOS.js (Animate on Scroll) |
| Carousel | Swiper.js |
| Hosting | Dedicated Server Prodi UNTAN via Puify |
| Version Control | Git + GitHub |
**Tidak boleh menggunakan:** Framework berat (React, Vue, Angular), backend (Node.js, PHP, Python), database (MySQL, MongoDB).
---
## 📐 Desain
- **Figma Project:** https://www.figma.com/design/rG6JYG19CWziaPMhbJ2NPL/PPL-FIGMA?node-id=0-1
- **Prototype:** https://www.figma.com/proto/rG6JYG19CWziaPMhbJ2NPL/PPL-FIGMA?node-id=120-1247
### Warna Utama
| Variabel | Hex | Penggunaan |
|---|---|---|
| Primary (Biru) | `#2B4590` | Navbar, heading, tombol, border aktif |
| Secondary (Kuning) | `#F5A623` | CTA, aksen, badge |
| Background | `#FFFFFF` | Latar halaman |
| Text | `#1A1A1A` | Body text |
| Accent | `#6B7280` | Caption, label, border |
| Success | `#10B981` | Badge "Buka" |
| Danger | `#EF4444` | Badge "Ditutup" |
| Overlay | `#00000080` | Background popup |
### Tipografi
- **Font:** Inter (utama) / Poppins (heading)
- H1: 4864px bold | H2: 3240px semibold | H3: 2428px semibold
- Body: 1618px regular | Caption: 1214px
---
## 📄 Struktur Halaman (Top → Bottom)
```
[NAVBAR - Sticky]
Logo UNTAN | Menu: Beranda, Beasiswa, Testimoni, Kontak | Toggle ID/EN
[#hero - Hero Section]
Judul: "Beasiswa Studi & Penelitian"
Sub: Teknik Informatika Universitas Tanjungpura
Deskripsi singkat tentang beasiswa
CTA Button (kuning): "Lihat Daftar Beasiswa →"
Foto ilustrasi mahasiswa (kanan)
[#statistics - Statistik Counter]
3 angka dengan animasi scroll:
- Total Mahasiswa Penerima Beasiswa Semester Ini
- Total Penerima Beasiswa Pembiayaan Pemerintah
- Total Penerima Beasiswa Pembiayaan Swasta
[#directory - Direktori Beasiswa]
- Tab filter: Semua | Sedang Buka | Ditutup | Pemerintah | Swasta
- Search bar (real-time)
- Grid kartu beasiswa:
* Desktop: 3 kolom
* Tablet: 2 kolom
* Mobile: 1 kolom
- Tiap kartu: thumbnail, badge status, nama, deskripsi singkat, tanggal, tombol "Selengkapnya →"
- Klik "Selengkapnya →" → buka POPUP MODAL
[POPUP MODAL - Detail Beasiswa]
- Nama + thumbnail/logo beasiswa
- Badge status (Buka / Ditutup)
- Syarat umum (ringkasan/bullet)
- Kontak yang bisa dihubungi
- Tombol "Info Lebih Lanjut" → buka URL sumber resmi (tab baru)
- Tutup: tombol ×, klik luar area, tekan Escape
[#testimonial - Testimoni Penerima Beasiswa]
- Carousel / grid kartu
- Tiap kartu: foto, nama, nama beasiswa yang diterima, kutipan singkat
- Navigasi carousel jika > 3 testimoni
[#cta - Call to Action]
- Judul: "Ingin Mengetahui Info Lebih Lanjut?"
- Dua tombol:
* "Daftar Beasiswa" (scroll ke #directory)
* "Hubungi Kami" (WhatsApp admin)
[#footer - Footer]
- Logo Informatika UNTAN
- Alamat prodi, email, telepon, WhatsApp
- Ikon media sosial
- Ilustrasi khas Pontianak (dekorasi bawah)
```
---
## 📁 Struktur File Proyek
```
project-root/
├── index.html
├── assets/
│ ├── css/
│ │ └── style.css
│ ├── js/
│ │ ├── main.js # Init AOS, smooth scroll, sticky navbar
│ │ ├── filter.js # Filter tab + real-time search
│ │ ├── popup.js # Modal detail beasiswa
│ │ ├── counter.js # Animasi counter statistik
│ │ ├── carousel.js # Carousel testimoni (Swiper.js)
│ │ └── i18n.js # Toggle bahasa ID/EN
│ └── images/
│ ├── hero/
│ ├── beasiswa/ # Logo/thumbnail beasiswa
│ └── testimoni/ # Foto penerima
├── data/
│ ├── beasiswa.json
│ └── testimoni.json
└── README.md
```
---
## 🗃️ Format Data
### `data/beasiswa.json`
```json
[
{
"id": "bsw-001",
"nama": "Beasiswa Adaro Foundation 2026",
"kategori": "Swasta",
"status": "Ditutup",
"thumbnail": "assets/images/beasiswa/adaro.jpg",
"deskripsi_singkat": "Beasiswa untuk mahasiswa S1 & D4 dengan IPK minimal 3.00...",
"syarat_umum": [
"Mahasiswa aktif S1/D4",
"IPK minimal 3.00",
"Berasal dari keluarga kurang mampu"
],
"kontak": "Admin Prodi / Kemahasiswaan UNTAN",
"tanggal_buka": "2026-01-01",
"tanggal_tutup": "2026-04-09",
"url_sumber": "https://...",
"tags": ["S1", "Swasta"]
}
]
```
### `data/testimoni.json`
```json
[
{
"id": "tsm-001",
"nama": "Aulia Rahman",
"foto": "assets/images/testimoni/aulia.jpg",
"nama_beasiswa": "Beasiswa Prestasi",
"kutipan": "Beasiswa ini benar-benar membuka jalan bagi saya untuk fokus sepenuhnya pada pendidikan..."
}
]
```
---
## ⚙️ Fitur Interaktif yang Harus Diimplementasikan
| Fitur | Status | File |
|---|---|---|
| Sticky Navbar (transparent → solid saat scroll) | ⏳ | `main.js` |
| Hamburger Menu | ⏳ | `main.js` |
| Smooth Scroll ke seksi | ⏳ | `main.js` |
| Active State Navbar (IntersectionObserver) | ⏳ | `main.js` |
| Animasi AOS (elemen muncul saat scroll) | ⏳ | `main.js` |
| Counter Animasi Statistik | ⏳ | `counter.js` |
| Render kartu beasiswa dari JSON | ⏳ | `filter.js` |
| Filter tab (Semua/Buka/Ditutup/Pemerintah/Swasta) | ⏳ | `filter.js` |
| Real-time search | ⏳ | `filter.js` |
| Popup Modal detail beasiswa | ⏳ | `popup.js` |
| Tutup popup (×, klik luar, Escape) | ⏳ | `popup.js` |
| Carousel testimoni (Swiper.js) | ⏳ | `carousel.js` |
| Toggle bahasa ID/EN | ⏳ | `i18n.js` |
---
## 📱 Responsive Breakpoints
```css
/* Mobile first */
/* Default: mobile < 768px */
/* Tablet: */ @media (min-width: 768px) { ... }
/* Laptop: */ @media (min-width: 1024px) { ... }
/* Desktop: */ @media (min-width: 1280px) { ... }
```
---
## 🔒 Aturan Keamanan Kode
- Semua link eksternal: `target="_blank" rel="noopener noreferrer"`
- Tidak ada hardcoded data sensitif di source code
- Input search di-sanitasi sebelum digunakan untuk filter DOM
- Tidak menggunakan `innerHTML` dengan input pengguna langsung (cegah XSS)
---
## 👥 Tim & Peran
| Nama | Peran |
|---|---|
| Djapianus Thebrianto | Project Manager |
| Derangga Aray Abimanyu | Backend Developer (data, scraping, JSON) |
| Destra Aulia Faza Ananda Putra | Frontend Developer (HTML, CSS, JS) |
| Dian Satrio Prayogo | UI/UX Designer (Figma) |
| Jonathan Geral Nababan | Quality Assurance (testing) |
---
## 📋 Hal Penting untuk AI Assistant
1. **Ini bukan React/Vue/Angular.** Semua harus ditulis dalam HTML/CSS/Tailwind/Vanilla JS.
2. **Data beasiswa dibaca dari `data/beasiswa.json`** — bukan hardcoded di HTML.
3. **Warna wajib sesuai palet resmi** — jangan gunakan warna lain sembarangan.
4. **Mobile-first** — tulis CSS dari ukuran kecil dulu, baru ke besar.
5. **Popup harus bisa ditutup 3 cara:** tombol ×, klik overlay, tekan Escape.
6. **Filter dan search bisa aktif bersamaan** (kumulatif, bukan saling menggantikan).
7. **Navbar berubah warna saat scroll** — transparent di atas hero, solid biru setelah scroll turun.
8. **Semua animasi pakai AOS.js** — inisialisasi di `main.js` dengan `AOS.init()`.
9. **Carousel testimoni pakai Swiper.js** — touch-friendly untuk mobile.
10. **Tidak ada `alert()`, `console.log()` di production code.**
---
## 🚀 Cara Kerja Deployment
```
Developer push ke GitHub
Puify detects push
Auto-deploy ke server
Website live (Dev)
Approved → Production
```
---
## 📎 Dokumen Terkait
- `SRS.md` — Spesifikasi kebutuhan lengkap (use case, NFR, batasan)
- `SDD.md` — Desain teknis lengkap (arsitektur, komponen, schema data)
- Figma: https://www.figma.com/design/rG6JYG19CWziaPMhbJ2NPL/PPL-FIGMA?node-id=0-1
+329
View File
@@ -0,0 +1,329 @@
# Software Design Document (SDD)
## Website Statis Halaman Beasiswa — One-Page HTML
### Program Studi Informatika — Universitas Tanjungpura
| Field | Detail |
|---|---|
| Nama Proyek | Pengembangan Website Statis Halaman Beasiswa |
| Kelompok | Ape Nama Team E |
| Versi | 1.0 |
| Fase SDLC | Design (aktif) |
| Dosen | Izuardo Zulkarnain, S.Pd., M.T |
| Semester | Genap 2025/2026 |
---
## 1. Pendahuluan
### 1.1 Tujuan Dokumen
SDD ini mendeskripsikan perancangan teknis website beasiswa Informatika UNTAN sebagai one-page HTML native. Dokumen ini menjadi panduan tim dalam mengimplementasikan:
- Fitur popup detail beasiswa
- Integrasi data hasil scraping & survei admin
- Otomatisasi deployment via Puify (push-to-deploy)
### 1.2 Stack Teknologi
| Layer | Teknologi | Keterangan |
|---|---|---|
| Markup | HTML5 | Struktur konten |
| Styling | CSS3 / Tailwind CSS | Layout responsif |
| Interaktivitas | JavaScript (Vanilla / Alpine.js) | Animasi, scroll, toggle |
| Font & Ikon | Google Fonts / Font Awesome | Tipografi & ikon |
| Hosting | Puify (SaaS) | Auto-deploy dari repo ke server |
| Server | Dedicated Server Prodi | Dev & Production |
| Version Control | Git + GitHub | Kolaborasi & push-to-deploy |
### 1.3 Posisi dalam SDLC
| Fase | Status |
|---|---|
| Requirement | ✅ Selesai |
| Design | 🔄 Aktif |
| Implementation | ⏳ Belum |
| Testing | ⏳ Belum |
| Deployment | ⏳ Belum |
---
## 2. Arsitektur Halaman (Site Architecture)
### 2.1 Konsep One-Page Website
Single-page statis — semua konten dalam satu file HTML utama. Navigasi menggunakan **smooth scroll**. Detail beasiswa (syarat umum) ditampilkan via **popup modal** agar halaman tetap bersih.
### 2.2 Struktur Seksi (Vertikal, Atas → Bawah)
| No | ID | Nama Seksi | Konten Utama |
|---|---|---|---|
| 1 | `#hero` | Hero / Landing | Judul "Beasiswa Studi & Penelitian", deskripsi, CTA "Lihat Daftar Beasiswa", foto ilustrasi mahasiswa |
| 2 | `#statistics` | Statistik Counter | 3 angka animasi: Total Penerima Semester Ini, Total Pemerintah, Total Swasta |
| 3 | `#directory` | Direktori Beasiswa | Grid kartu (3 kolom desktop, 2 tablet, 1 mobile), tab filter, real-time search, popup detail |
| 4 | `#testimonial` | Testimoni | Carousel/grid testimoni: foto, nama, beasiswa, kutipan singkat |
| 5 | `#cta` | Call-to-Action | Banner ajakan + tombol WhatsApp admin + Instagram kemahasiswaan |
| 6 | `#footer` | Footer | Logo prodi, alamat, email, telepon, WA, ikon sosmed, dekorasi ilustrasi Pontianak |
### 2.3 Navigasi
**Sticky navbar** yang selalu terlihat saat scroll.
| Perangkat | Perilaku Navbar |
|---|---|
| Desktop/Laptop | Horizontal, semua item terlihat |
| Tablet | Horizontal, item muat; sisanya dropdown |
| Mobile | Hamburger menu (≡) — tap untuk expand vertikal |
---
## 3. Desain Visual & Identitas
### 3.1 Color Palette
| Peran | Nama | Hex | Digunakan Untuk |
|---|---|---|---|
| Primary | Biru Informatika | `#2B4590` | Navbar, heading, tombol primary, border aktif |
| Secondary | Kuning/Oranye | `#F5A623` | CTA button, aksen, badge |
| Background | White | `#FFFFFF` | Latar halaman utama |
| Text | Dark Gray | `#1A1A1A` | Body text, paragraf |
| Accent | Abu-abu | `#6B7280` | Caption, label, border |
| Success | Hijau | `#10B981` | Badge status "Buka" |
| Danger | Merah | `#EF4444` | Badge status "Ditutup" |
| Overlay | Hitam + Transparansi | `#00000080` | Background popup modal |
### 3.2 Tipografi
| Elemen | Font Family | Ukuran | Weight |
|---|---|---|---|
| Heading H1 | Poppins / Inter | 4864px | 700 (Bold) |
| Heading H2 | Poppins / Inter | 3240px | 600 (SemiBold) |
| Heading H3 | Poppins / Inter | 2428px | 600 (SemiBold) |
| Body Text | Inter / Poppins | 1618px | 400 (Regular) |
| Caption/Label | Inter / Poppins | 1214px | 400500 |
| Navbar | Inter | 1416px | 500 (Medium) |
| Button | Inter | 1416px | 600 (SemiBold) |
### 3.3 Komponen UI
| Komponen | Spesifikasi |
|---|---|
| Navbar | Sticky; transparent di atas hero → solid biru `#2B4590` saat scroll; toggle ID/EN di kanan |
| Hero Section | Full-height, overlay gradient gelap, judul besar, deskripsi, CTA kuning kontras |
| Card Beasiswa | Border subtle, shadow ringan; hover: scale sedikit + shadow lebih dalam; thumbnail 100% lebar |
| Button Primary | Solid biru, teks putih |
| Button Secondary | Outline biru |
| Button Ghost | Tanpa border |
| Badge Status | Rounded pill; Hijau "Buka", Merah "Ditutup" |
| Tab Filter | Underline active state, smooth transition, teks hitam, garis bawah aktif biru |
| Popup Modal | Center screen, dark overlay, border-radius 12px, close button (×) kanan atas |
| Footer | Background `#1A1A1A`, teks putih, 34 kolom desktop |
---
## 4. Desain Mockup (UI/UX)
### 4.1 Alat Desain
**Figma** — kolaborasi realtime, wireframe + hi-fidelity prototype.
- **Figma Project:** https://www.figma.com/design/rG6JYG19CWziaPMhbJ2NPL/PPL-FIGMA?node-id=0-1
- **Prototype Interaktif:** https://www.figma.com/proto/rG6JYG19CWziaPMhbJ2NPL/PPL-FIGMA?node-id=120-1247
### 4.2 Tahapan Desain
1. **Wireframe (Lo-Fi)** — sketsa hitam-putih, fokus layout & posisi elemen
2. **Mockup (Hi-Fi)** — desain lengkap dengan warna, font, dan aset visual
3. **Prototype** — mockup yang bisa diklik untuk simulasi navigasi antar seksi
4. **User Testing** — validasi desain bersama calon pengguna
### 4.3 Breakpoint & Resolusi
| Perangkat | Resolusi Mockup |
|---|---|
| Desktop Monitor | 1920 × 1080 |
| Laptop | 1366 × 768 |
| Tablet Portrait | 768 × 1024 |
| Tablet Landscape | 1024 × 768 |
| Mobile / HP | 390 × 844 |
---
## 5. Desain Responsif
### 5.1 Strategi: Mobile-First
Desain dimulai dari layar terkecil → diperluas ke layar lebih besar via CSS media query. Memastikan performa optimal di koneksi lambat.
### 5.2 Breakpoint
| Perangkat | Breakpoint | Resolusi Umum |
|---|---|---|
| Desktop Monitor | `>= 1280px` | 1920×1080, 2560×1440 |
| Laptop | `>= 1024px` | 1366×768, 1440×900 |
| Tablet | `>= 768px` | 768×1024, 820×1180 |
| Mobile | `< 768px` | 360×800, 390×844, 414×896 |
### 5.3 Perubahan Layout per Breakpoint
| Elemen | Mobile (<768px) | Tablet (7681279px) | Desktop (≥1280px) |
|---|---|---|---|
| Navbar | Hamburger menu | Hamburger / partial menu | Horizontal full menu |
| Hero Section | Teks atas, gambar bawah | Teks kiri, gambar kanan 50/50 | Teks kiri, gambar kanan lebar |
| Card Grid | 1 kolom | 2 kolom | 34 kolom |
| Teks Body | 16px | 1617px | 1718px |
| Padding Section | 24px 16px | 40px 32px | 80px 64px |
| Footer | Stack vertikal | 2 kolom | 34 kolom |
### 5.4 Hasil Uji Responsivitas
| No | Perangkat/Browser | Ukuran | Status | Catatan |
|---|---|---|---|---|
| 1 | Chrome Desktop | 1920×1080 | ✅ Lulus | Layout proporsional, spacing rapi |
| 2 | Chrome Mobile Sim. | 390×844 | ✅ Lulus | Card grid 1 kolom, teks 16px nyaman |
| 3 | Firefox Desktop | 1440×900 | ✅ Lulus | Konsisten dengan Chrome Desktop |
| 4 | iPhone (Safari) | 390×844 | ✅ Lulus | Hero stack sempurna, padding aman |
| 5 | Android Chrome | 360×800 | ✅ Lulus | Touch target ideal, gambar proporsional |
| 6 | iPad (Safari) | 768×1024 | ✅ Lulus | Hero 50/50, spasi sentuh lega |
---
## 6. Use Case Diagram
### 6.1 Aktor Sistem
| Aktor | Deskripsi |
|---|---|
| Pengunjung Umum | Mahasiswa aktif, alumni, calon mahasiswa, dosen, masyarakat — tanpa autentikasi |
| Admin Prodi | Pengelola konten: memperbarui data beasiswa, testimoni, statistik via push-to-repo |
### 6.2 Daftar Use Case & Skenario
Lihat detail lengkap di SRS.md. Ringkasan:
| Kode | Nama | Aktor |
|---|---|---|
| UC-01 | Melihat Daftar Beasiswa | Pengunjung Umum |
| UC-02 | Memfilter Beasiswa Berdasarkan Kategori | Pengunjung Umum |
| UC-03 | Mencari Beasiswa | Pengunjung Umum |
| UC-04 | Melihat Detail Beasiswa (Popup) | Pengunjung Umum |
| UC-05 | Mengakses Sumber Resmi Beasiswa | Pengunjung Umum |
| UC-06 | Menghubungi Admin via CTA | Pengunjung Umum |
| UC-07 | Melihat Testimoni Penerima Beasiswa | Pengunjung Umum |
| UC-08 | Mengganti Bahasa Tampilan (ID/EN) | Pengunjung Umum |
| UC-09 | Memperbarui Data Beasiswa | Admin Prodi |
**Relasi antar UC:** UC-04 `<<include>>` UC-05 (popup detail menyertakan akses sumber resmi)
---
## 7. Fitur Interaktif (Client-Side)
Tidak ada backend API. Semua interaktivitas dijalankan di sisi klien.
| No | Fitur | Teknologi | Keterangan |
|---|---|---|---|
| 1 | Smooth Scroll Navigasi | CSS `scroll-behavior` / JS | Scroll halus ke seksi saat klik menu |
| 2 | Sticky Navbar | CSS `position:sticky` / JS scroll | Navbar tetap di atas saat scroll |
| 3 | Hamburger Menu | JavaScript + CSS toggle | Menu responsif layar kecil |
| 4 | Active State Navbar | JS `IntersectionObserver` | Highlight menu sesuai seksi terlihat |
| 5 | Animasi Masuk (AOS) | AOS.js / CSS animation | Elemen muncul saat pertama kali di viewport |
| 6 | Counter Animasi | JavaScript | Angka statistik berjalan saat di-scroll |
| 7 | Filter Real-Time | Vanilla JavaScript | Filter kartu saat klik tab, tanpa reload |
| 8 | Pencarian Real-Time | Vanilla JavaScript | Cari beasiswa saat ketik di search bar |
| 9 | Popup Modal Detail | Vanilla JavaScript | Detail beasiswa dalam modal; tutup via ×, klik luar, atau Escape |
| 10 | Toggle Bahasa ID/EN | Vanilla JS + localStorage | Ubah teks tanpa reload, simpan preference di session |
| 11 | Carousel Testimoni | Vanilla JS / Swiper.js | Navigasi testimoni left/right; touch swipe di mobile |
| 12 | Form Kontak (Opsional) | Formspree / EmailJS / mailto | Kirim pesan tanpa backend |
| 13 | Lightbox Galeri (Opsional) | Fancybox / vanilla JS | Gambar fullscreen saat diklik |
---
## 8. Layanan Pihak Ketiga
| Layanan | Kegunaan | Gratis? |
|---|---|---|
| Google Fonts | Tipografi (Inter/Poppins) | ✅ Ya |
| Font Awesome | Ikon UI | ✅ Ya (tier dasar) |
| Google Maps Embed | Peta lokasi di seksi kontak | ✅ Ya (basic embed) |
| Formspree | Form kontak tanpa backend | ✅ Ya (tier dasar) |
| AOS.js | Animasi scroll (Animate on Scroll) | ✅ Ya (open source) |
| Swiper.js | Carousel testimoni | ✅ Ya (open source) |
---
## 9. Desain Keamanan
### 9.1 Keamanan Form Kontak
- Validasi client-side: semua field wajib diisi, format email dicek sebelum submit
- Rate limiting: Formspree/EmailJS membatasi pengiriman untuk mencegah spam
- reCAPTCHA (Opsional): Google reCAPTCHA v3 untuk mencegah bot
### 9.2 Keamanan Konten
- **Tidak ada data sensitif** — website tidak menyimpan data pengguna (no login, no database)
- **HTTPS wajib** — sertifikat SSL/TLS pada server hosting prodi
- **Content Security Policy** — meta CSP header untuk mencegah XSS
- **External link** — semua link eksternal menggunakan `rel="noopener noreferrer"`
---
## 10. Struktur File yang Direkomendasikan
```
project-root/
├── index.html # File HTML utama (one-page)
├── assets/
│ ├── css/
│ │ └── style.css # Custom CSS (jika tidak full Tailwind)
│ ├── js/
│ │ ├── main.js # Logika utama: scroll, navbar, AOS init
│ │ ├── filter.js # Filter & pencarian beasiswa
│ │ ├── popup.js # Modal popup detail beasiswa
│ │ ├── counter.js # Animasi counter statistik
│ │ ├── carousel.js # Carousel testimoni
│ │ └── i18n.js # Toggle bahasa ID/EN
│ └── images/
│ ├── hero/ # Foto hero section
│ ├── beasiswa/ # Thumbnail logo beasiswa
│ └── testimoni/ # Foto penerima beasiswa
├── data/
│ ├── beasiswa.json # Data beasiswa (diupdate oleh admin)
│ └── testimoni.json # Data testimoni
└── README.md # Panduan setup & deployment
```
---
## 11. Data Schema
### 11.1 Schema Beasiswa (`beasiswa.json`)
```json
{
"id": "string",
"nama": "string",
"kategori": "Pemerintah | Swasta",
"status": "Buka | Ditutup",
"thumbnail": "path/to/image.jpg",
"deskripsi_singkat": "string (max 150 char)",
"syarat_umum": ["string", "string"],
"kontak": "string",
"tanggal_buka": "YYYY-MM-DD",
"tanggal_tutup": "YYYY-MM-DD",
"url_sumber": "https://...",
"tags": ["S1", "S2", "Magang", "Luar Negeri"]
}
```
### 11.2 Schema Testimoni (`testimoni.json`)
```json
{
"id": "string",
"nama": "string",
"foto": "path/to/foto.jpg",
"nama_beasiswa": "string",
"kutipan": "string (max 200 char)"
}
```
---
## 12. Referensi
1. IEEE Standard 1016-2009 — Software Design Descriptions
2. W3C — HTML5 & CSS3 Specification (https://www.w3.org)
3. Google — Web Fundamentals: Responsive Web Design
4. MDN Web Docs — CSS Media Queries
5. Figma — Design Tool Documentation
6. Nurhikmat et al. (2024) — Optimasi Responsivitas Web dengan Mobile-First Design. JINTEKS.
+337
View File
@@ -0,0 +1,337 @@
# Software Requirements Specification (SRS)
## Pengembangan Website Statis Halaman Beasiswa
### Program Studi Informatika — Universitas Tanjungpura
| Field | Detail |
|---|---|
| Nama Proyek | Pengembangan Website Statis Halaman Beasiswa |
| Kelompok | Ape Nama Team E |
| Versi | 1.1 |
| Tanggal | 01/05/2026 |
| Dosen | Izuardo Zulkarnain, S.Pd., M.T |
| Mata Kuliah | Proyek Perangkat Lunak — Semester Genap 2025/2026 |
**Anggota Tim:**
- Djapianus Thebrianto — Project Manager
- Derangga Aray Abimanyu — Backend Developer
- Destra Aulia Faza Ananda Putra — Frontend Developer
- Dian Satrio Prayogo — UI/UX Designer
- Jonathan Geral Nababan — Quality Assurance
---
## 1. Pendahuluan
### 1.1 Tujuan Dokumen
SRS ini memberikan spesifikasi teknis dan fungsional untuk pengembangan Website Statis Beasiswa Informatika Universitas Tanjungpura. Dokumen ini memastikan:
- Pengembang membangun sistem dengan kode sederhana yang mudah di-maintain melalui mekanisme push-to-repo ke dedicated server prodi.
- Penyajian data beasiswa (nama & syarat umum) dilakukan melalui fitur **Pop-up** dengan akses langsung ke admin via WhatsApp dan Instagram kemahasiswaan UNTAN.
- Proyek mengalihkan alur data dari portal universitas/survei admin ke static page dengan **availability 24 jam**.
**Ditujukan kepada:** Tim Ape Nama Team E, Product Owner (dosen), dan Administrator Prodi.
### 1.2 Ruang Lingkup
Website statis single-page yang menjadi pusat informasi beasiswa, riset, dan pengembangan karir — terintegrasi dengan infrastruktur server Prodi Informatika UNTAN.
**Fitur yang dicakup:**
- Direktori beasiswa (mahasiswa aktif, alumni, S2 luar negeri, magang)
- Detail beasiswa via popup (nama + syarat umum)
- Data beasiswa dari web scraping sumber resmi
- Seksi testimoni penerima beasiswa (foto, profil, kutipan)
- Toggle bahasa Indonesia / Inggris (bilingual)
- CTA ke WhatsApp admin & Instagram kemahasiswaan UNTAN
**Yang TIDAK dicakup:**
- Formulir pendaftaran (diarahkan ke URL asli penyedia)
- Halaman admin berbasis web (update via push-to-repo)
- Database — data bersifat statis (JSON/JS di repositori)
### 1.3 Definisi & Akronim
| Istilah | Definisi |
|---|---|
| Native Website | Web dengan HTML, CSS, JS murni tanpa framework kompleks |
| Scraping | Pengambilan data otomatis dari website eksternal |
| Dedicated Server | Server milik Prodi Informatika UNTAN (Dev & Production) |
| Pop-up / Modal | Jendela overlay di atas halaman tanpa reload |
| CTA | Call-to-Action — tombol ajakan ke WhatsApp/Instagram |
| Availability | Kemampuan sistem beroperasi 24 jam |
| Puify | Platform SaaS otomatisasi deployment dari repo ke server |
### 1.4 Referensi
- IEEE Std 830-1998 — Software Requirements Specifications
- UU No. 27 Tahun 2022 tentang Perlindungan Data Pribadi (UU PDP)
---
## 2. Deskripsi Umum Sistem
### 2.1 Perspektif Produk
Produk baru berdiri sendiri sebagai **single static landing page** — bukan pengganti sistem lama. Website ini hadir sebagai transformasi digital dari penyebaran info beasiswa yang sebelumnya manual (portal, surat kabar mahasiswa, grup medsos) ke satu platform terpusat.
**Integrasi eksternal (satu arah):**
- Website resmi penyedia beasiswa (data via scraping)
- API Website Informatika UNTAN (konten artikel/berita)
- WhatsApp & Instagram Kemahasiswaan UNTAN (CTA)
Tidak ada antarmuka admin berbasis web. Semua update konten via **push ke GitHub → auto-deploy ke server** melalui Puify.
### 2.2 Fungsi Utama Sistem
| Kode | Fungsi | Deskripsi |
|---|---|---|
| F01 | Direktori Beasiswa | Grid kartu beasiswa aktif, S2, magang, penelitian dari berbagai sumber resmi |
| F02 | Filter & Pencarian | Saring berdasarkan kategori (Semua/Buka/Ditutup/Pemerintah/Swasta) + real-time search |
| F03 | Detail via Pop-up | Modal berisi nama, syarat umum, kontak, tautan sumber resmi |
| F04 | Testimoni | Foto, nama, nama beasiswa, kutipan dari penerima beasiswa |
| F05 | CTA Hubungi Admin | Tombol cepat ke WhatsApp admin & Instagram kemahasiswaan |
| F06 | Bilingual ID/EN | Toggle bahasa Indonesia ↔ Inggris tanpa reload |
| F07 | Update via Repository | Admin edit file data + push → halaman publik auto-update |
### 2.3 Kelas Pengguna
| Kelas | Karakteristik | Hak Akses |
|---|---|---|
| Pengunjung Umum | Mahasiswa aktif, alumni, masyarakat — tanpa login | Lihat konten, filter, search, buka popup, akses link eksternal |
| Admin Prodi | Staf/mahasiswa dengan kemampuan Git dasar | Edit file data di GitHub → push → deploy otomatis |
### 2.4 Lingkungan Operasional
| Komponen | Spesifikasi |
|---|---|
| Platform | Browser modern: Chrome ≥v100, Firefox ≥v100, Edge ≥v100, Safari ≥v15 |
| Server/Hosting | Dedicated server Prodi Informatika UNTAN (Dev & Production) via Puify |
| Database | Tidak ada — data di file statis JSON/JS di repositori |
| Bahasa Pemrograman | HTML5, CSS3 + Tailwind CSS, JavaScript ES6+ |
| Version Control | Git / GitHub |
| Koneksi | Min 1 Mbps (dasar), 10 Mbps (optimal) |
### 2.5 Asumsi & Ketergantungan
**Asumsi:**
- Pengunjung menggunakan browser modern dengan JS ES6+ dan CSS3 aktif
- Data beasiswa sudah diverifikasi admin sebelum dipublikasikan
- Perangkat pengunjung mendukung akses WhatsApp (app atau web)
**Ketergantungan:**
- Dedicated server Prodi UNTAN harus aktif untuk availability 24 jam
- Update data bergantung pada keaktifan admin melakukan push secara berkala
- Fitur berita bergantung pada stabilitas API Website Informatika UNTAN
- CTA bergantung pada keaktifan nomor WhatsApp & akun Instagram kemahasiswaan
---
## 3. Kebutuhan Fungsional
### 3.1 Daftar Use Case
| UC ID | Nama Use Case | Aktor | Prioritas (MoSCoW) |
|---|---|---|---|
| UC-01 | Melihat Daftar Beasiswa | Pengunjung Umum | Must Have |
| UC-02 | Memfilter Beasiswa Berdasarkan Kategori | Pengunjung Umum | Must Have |
| UC-03 | Mencari Beasiswa | Pengunjung Umum | Must Have |
| UC-04 | Melihat Detail Beasiswa (Popup) | Pengunjung Umum | Must Have |
| UC-05 | Mengakses Sumber Informasi Beasiswa | Pengunjung Umum | Must Have |
| UC-06 | Menghubungi Admin via CTA | Pengunjung Umum | Must Have |
| UC-07 | Melihat Testimoni Penerima Beasiswa | Pengunjung Umum | Should Have |
| UC-08 | Mengganti Bahasa Tampilan (ID/EN) | Pengunjung Umum | Should Have |
| UC-09 | Memperbarui Data Beasiswa | Admin Prodi | Must Have |
### 3.2 Detail Use Case
#### UC-01: Melihat Daftar Beasiswa
| Field | Detail |
|---|---|
| Aktor | Pengunjung Umum |
| Precondition | Halaman berhasil dimuat di browser |
| Trigger | Pengunjung membuka URL atau scroll ke seksi Daftar Beasiswa |
| Main Flow | 1. Pengunjung buka URL halaman<br>2. Sistem tampilkan hero, statistik, daftar beasiswa<br>3. Kartu ditampilkan grid: 3 kolom desktop / 2 tablet / 1 mobile<br>4. Tiap kartu: thumbnail, badge status, nama, deskripsi singkat, tanggal, tombol "Selengkapnya"<br>5. Paginasi tampil di bawah grid jika beasiswa > 1 halaman |
| Alt Flow | 3a. Koneksi lambat → skeleton loading<br>3b. Tidak ada data → tampil "Belum ada beasiswa tersedia saat ini." |
| Postcondition | Pengunjung dapat melihat seluruh daftar beasiswa |
#### UC-02: Memfilter Beasiswa Berdasarkan Kategori
| Field | Detail |
|---|---|
| Aktor | Pengunjung Umum |
| Precondition | Halaman dimuat, daftar beasiswa tampil |
| Trigger | Pengunjung klik salah satu tab filter |
| Main Flow | 1. Tampil tab: Semua \| Sedang Buka \| Ditutup \| Pemerintah \| Swasta<br>2. Pengunjung klik tab<br>3. Sistem real-time filter beasiswa sesuai kategori<br>4. Tab aktif ditandai bold/underline/warna<br>5. Jumlah hasil diperbarui |
| Alt Flow | 3a. Tidak ada hasil → "Tidak ada beasiswa pada kategori ini."<br>3b. Klik "Semua" → tampil semua beasiswa |
| Postcondition | Daftar beasiswa sesuai filter yang dipilih |
#### UC-03: Mencari Beasiswa
| Field | Detail |
|---|---|
| Aktor | Pengunjung Umum |
| Precondition | Halaman dimuat, kolom pencarian tersedia |
| Trigger | Pengunjung mengetik di kolom pencarian |
| Main Flow | 1. Pengunjung klik kolom pencarian<br>2. Ketik kata kunci (misal: "Pertamina", "KIP", "luar negeri")<br>3. Sistem real-time filter kartu yang nama/deskripsinya cocok<br>4. Hapus kata kunci → kembali ke tampilan penuh<br>5. Filter dan pencarian dapat aktif bersamaan (kumulatif) |
| Alt Flow | 3a. Tidak ada hasil → "Tidak ditemukan beasiswa yang sesuai pencarian Anda." |
| Postcondition | Daftar beasiswa sesuai kata kunci |
#### UC-04: Melihat Detail Beasiswa (Popup)
| Field | Detail |
|---|---|
| Aktor | Pengunjung Umum |
| Precondition | Kartu beasiswa tampil, pengunjung menemukan beasiswa yang diinginkan |
| Trigger | Klik tombol "Selengkapnya →" pada kartu |
| Main Flow | 1. Klik "Selengkapnya →"<br>2. Sistem tampilkan modal popup berisi: nama + thumbnail, badge status, syarat umum ringkasan, kontak, tombol "Info Lebih Lanjut" (→ URL sumber resmi di tab baru)<br>3. Background di-overlay gelap<br>4. Tutup via: tombol ×, klik di luar area, atau tekan Escape |
| Alt Flow | 4a. Klik "Info Lebih Lanjut" → jalankan UC-05 |
| Postcondition | Pengunjung mendapat info ringkas dan dapat lanjut ke sumber resmi |
#### UC-05: Mengakses Sumber Informasi Beasiswa
| Field | Detail |
|---|---|
| Aktor | Pengunjung Umum |
| Precondition | Popup detail terbuka, tombol "Info Lebih Lanjut" tersedia |
| Trigger | Klik tombol "Info Lebih Lanjut" di popup |
| Main Flow | 1. Klik tombol<br>2. Sistem buka URL sumber resmi di tab baru<br>3. Popup di tab asal tetap terbuka |
| Alt Flow | 3a. URL tidak tersedia → "Tautan tidak tersedia. Silakan hubungi admin prodi." |
| Postcondition | Pengunjung diarahkan ke sumber resmi beasiswa |
#### UC-06: Menghubungi Admin via CTA
| Field | Detail |
|---|---|
| Aktor | Pengunjung Umum |
| Precondition | Pengunjung ingin info lebih lanjut |
| Trigger | Scroll ke seksi CTA, klik salah satu tombol |
| Main Flow | 1. Scroll ke CTA ("Ingin Tahu Lebih Banyak tentang Beasiswa?")<br>2. Tampil dua tombol: "Hubungi Admin" (WhatsApp) + "Instagram Beasiswa" (IG kemahasiswaan)<br>3. Klik tombol → buka platform di tab baru |
| Alt Flow | 4a. Tanpa WhatsApp terinstall → buka WhatsApp Web |
| Postcondition | Pengunjung terhubung dengan admin atau follow Instagram |
#### UC-07: Melihat Testimoni Penerima Beasiswa
| Field | Detail |
|---|---|
| Aktor | Pengunjung Umum |
| Precondition | Halaman dimuat, data testimoni tersedia di source code |
| Trigger | Scroll ke seksi Testimoni |
| Main Flow | 1. Scroll ke seksi Testimoni<br>2. Tampil kartu testimoni: foto, nama, nama beasiswa, kutipan singkat<br>3. Jika > 3 testimoni → navigasi carousel/tombol selanjutnya |
| Alt Flow | 2a. Belum ada data → seksi tidak ditampilkan atau tampil placeholder |
| Postcondition | Pengunjung mendapat gambaran pengalaman penerima beasiswa |
#### UC-08: Mengganti Bahasa Tampilan (ID/EN)
| Field | Detail |
|---|---|
| Aktor | Pengunjung Umum |
| Precondition | Toggle ID\|EN tersedia di header navigasi |
| Trigger | Klik toggle bahasa di pojok kanan header |
| Main Flow | 1. Pengunjung lihat toggle ID \| EN di kanan atas<br>2. Klik bahasa yang diinginkan<br>3. Seluruh teks berubah tanpa reload<br>4. Pilihan tersimpan di session (tidak reset saat scroll/filter) |
| Alt Flow | 3a. Konten hanya tersedia 1 bahasa → tetap tampil bahasa aslinya |
| Postcondition | Halaman tampil dalam bahasa yang dipilih |
#### UC-09: Memperbarui Data Beasiswa
| Field | Detail |
|---|---|
| Aktor | Admin Prodi |
| Precondition | Admin punya akses repo, ada info beasiswa baru/perubahan status |
| Trigger | Admin mendapat informasi update dari survei/portal/sumber lain |
| Main Flow | 1. Admin dapat info baru/perubahan status<br>2. Edit file data beasiswa (nama, kategori, status, syarat, kontak, URL)<br>3. Simpan dan push ke GitHub<br>4. Halaman publik auto-update dalam waktu singkat via Puify |
| Alt Flow | 3a. Error saat deploy → halaman publik tidak berubah, admin perbaiki dan push ulang |
| Postcondition | Info beasiswa terbaru tersedia di halaman publik 24/7 |
---
## 4. Kebutuhan Antarmuka Eksternal
### 4.1 Antarmuka Pengguna (UI)
- Responsive design: breakpoint 320px (mobile), 768px (tablet), 1280px (desktop)
- Warna utama: Biru Informatika `#2B4590` dan Kuning `#F5A623` (branding UNTAN)
- Font: Inter atau Poppins, minimal 14px untuk body text
- Waktu respons antarmuka ≤ 3 detik pada koneksi 10 Mbps
- Aksesibilitas WCAG 2.1 level AA (kontras warna, ARIA label, navigasi keyboard)
- Semua elemen interaktif memiliki feedback visual saat hover/klik
**Halaman / Seksi:**
| Seksi | ID | Konten Utama |
|---|---|---|
| Header & Navigasi | — | Navbar sticky, logo UNTAN, toggle ID/EN, info ticker |
| Hero | `#hero` | Judul "Beasiswa Studi & Penelitian", deskripsi, CTA kuning, foto mahasiswa |
| Statistik Counter | `#statistics` | 3 angka animasi: Total Penerima, Pemerintah, Swasta |
| Direktori Beasiswa | `#directory` | Grid kartu, tab filter, real-time search, popup detail |
| Testimoni | `#testimonial` | Carousel foto+nama+kutipan penerima beasiswa |
| CTA | `#cta` | Banner ajakan + tombol WhatsApp & Instagram |
| Footer | `#footer` | Logo, alamat, kontak, sosial media, ilustrasi Pontianak |
### 4.2 Antarmuka Hardware
- Browser modern: Chrome/Firefox/Edge ≥v100, Safari ≥v15
- Koneksi minimal 1 Mbps; optimal 10 Mbps
- Resolusi minimal 320px lebar
- Tidak butuh hardware khusus (kamera, printer, sensor, dll.)
### 4.3 Antarmuka Software
| Layanan | Tujuan | Protokol |
|---|---|---|
| Dedicated Server + Puify | Hosting dev & production, auto-deploy dari GitHub | HTTPS |
| GitHub | Version control, kolaborasi, deliverable akhir | HTTPS/SSH |
| Sumber Resmi Beasiswa | Link eksternal di popup (dibuka tab baru) | HTTPS |
| WhatsApp / Instagram UNTAN | Tombol CTA | HTTPS / wa.me |
| Figma | Desain mockup & wireframe acuan implementasi | HTTPS |
| Web Scraper | Ambil data beasiswa dari sumber resmi | HTTPS |
### 4.4 Antarmuka Komunikasi
- Semua file dikirim via HTTPS dengan sertifikat SSL/TLS valid
- Fitur interaktif (filter, search, toggle bahasa, popup, counter) berjalan client-side (JS) — tanpa request ke server
- Semua link eksternal: `target="_blank"` + `rel="noopener noreferrer"`
- Tidak menggunakan cookie, session storage, atau autentikasi pengguna (v1.0)
---
## 5. Kebutuhan Non-Fungsional
### NFR-01: Performa
- **P-01** Loading halaman ≤ 3 detik pada koneksi 10 Mbps
- **P-02** Filter beasiswa merespons < 0,5 detik setelah klik
- **P-03** Tetap responsif dan optimal di desktop, tablet, dan smartphone
### NFR-02: Keamanan
- **S-01** Semua komunikasi via HTTPS dengan SSL/TLS
- **S-02** Source code bersih dari script berbahaya, bebas celah XSS
- **S-03** Semua link CTA dan pendaftaran mengarah ke sumber resmi tervalidasi
### NFR-03: Skalabilitas
- **SC-01** Mampu menampilkan minimal 100 data beasiswa tanpa penurunan performa
- **SC-02** Struktur kode memungkinkan penambahan kategori baru tanpa perubahan besar
- **SC-03** Sistem bilingual mendukung penambahan bahasa baru di masa depan
### NFR-04: Ketersediaan & Reliabilitas
- **R-01** Uptime minimal 99% selama masa operasional
- **R-02** Dapat diakses 24 jam selama server aktif
- **R-03** Backup source code via Git/GitHub untuk pemulihan data
### NFR-05: Usability
- **U-01** Antarmuka bilingual ID/EN
- **U-02** Pengguna menemukan info beasiswa dalam < 2 menit via navigasi & filter
- **U-03** Tombol CTA di Hero kontras dan posisinya jelas
### NFR-06: Maintainability
- **M-01** Tech stack: HTML5, CSS3 + Tailwind CSS, JavaScript ES6+ — kode rapi dan mudah dipelihara
- **M-02** Semua perubahan dikelola via Git/GitHub (version control)
- **M-03** Dokumentasi teknis & panduan deployment disertakan saat penyerahan akhir
---
## 6. Batasan & Kendala
### 6.1 Batasan Implementasi
- Tech stack terkunci: HTML5, CSS3, Tailwind CSS, JavaScript ES6+, Figma, Git/GitHub
- Single static landing page — tidak ada backend, database, atau login
- Harus berjalan optimal di Chrome, Firefox, Edge versi terbaru
- Wajib responsif: desktop, tablet, smartphone
### 6.2 Batasan Proyek
- Durasi: 10 hari kerja (1 Mei 11 Mei 2026)
- Tim: 5 orang dengan peran tetap (PM, Backend, Frontend, UI/UX, QA)
- Revisi antarmuka/konten sesuai batas yang disepakati
- Penambahan fitur baru saat development = di luar ruang lingkup utama
### 6.3 Regulasi & Standar
- UU PDP No. 27/2022 untuk pengelolaan data testimoni mahasiswa
- Visual harus selaras branding resmi Informatika UNTAN
- Memenuhi prinsip aksesibilitas dasar (navigasi jelas, tampilan informatif)
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 KiB

+81
View File
@@ -0,0 +1,81 @@
// =====================================================
// API.js — Mekanisme Fetch Data Beasiswa (terpusat)
// -----------------------------------------------------
// Alur: coba API remote (Directus publik) → jika gagal,
// fallback ke berkas lokal `data/beasiswa.json`.
//
// Data remote dinormalisasi ke skema UI yang sama dengan
// data lokal agar filter.js dan popup.js tidak perlu berubah.
// =====================================================
(function (global) {
'use strict';
const CONFIG = {
baseUrl: 'https://api.ifuntanhub.dev',
collection: '/items/beasiswa',
localSource: 'data/beasiswa.json',
};
// Normalise status remote → nilai yang dipakai UI
function normalizeStatus(s) {
if (!s) return 'Ditutup';
const lower = s.toLowerCase();
if (lower === 'dibuka' || lower === 'buka') return 'Buka';
if (lower === 'ditutup') return 'Ditutup';
return s; // "Segera Dibuka" atau nilai lain: tampilkan apa adanya
}
// Petakan satu item Directus ke skema yang diharapkan filter.js / popup.js
function normalizeItem(item) {
const parts = [item.jenjang, item.negara, item.tipe_pendanaan].filter(Boolean);
return {
id: String(item.id),
nama: item.nama || '',
status: normalizeStatus(item.status),
kategori: item.tipe_pendanaan || '',
thumbnail: '',
deskripsi_singkat: parts.join(' · ') || 'Info beasiswa tersedia di sumber resmi.',
tanggal_buka: item.tanggal_buka || '',
tanggal_tutup: item.tenggat_waktu || '',
url_sumber: item.url || '',
syarat_umum: [],
kontak: '',
tags: parts,
};
}
async function fetchRemote() {
const res = await fetch(CONFIG.baseUrl + CONFIG.collection);
if (!res.ok) throw new Error(`API merespons HTTP ${res.status}`);
const json = await res.json();
return (json.data || []).map(normalizeItem);
}
async function fetchLocal() {
const res = await fetch(CONFIG.localSource);
if (!res.ok) throw new Error(`Gagal memuat data lokal (HTTP ${res.status})`);
const json = await res.json();
return Array.isArray(json) ? json : json.data || [];
}
async function getBeasiswa() {
try {
// Prioritas: data lokal (lengkap: url_sumber, syarat_umum, kontak tersedia)
// Remote API digunakan hanya sebagai fallback jika data lokal tidak ditemukan.
const data = await fetchLocal();
return { data, source: 'local' };
} catch (err) {
console.warn('[BeasiswaAPI] Data lokal gagal, fallback ke API remote:', err.message);
try {
const data = await fetchRemote();
return { data, source: 'remote' };
} catch (remoteErr) {
console.error('[BeasiswaAPI] Semua sumber gagal:', remoteErr.message);
throw remoteErr;
}
}
}
global.BeasiswaAPI = { config: CONFIG, getBeasiswa, fetchRemote, fetchLocal };
})(window);
+140
View File
@@ -0,0 +1,140 @@
// =====================================================
// carousel.js — Swiper.js Carousel Testimoni
// Touch-friendly, auto-play, responsive breakpoints
// + DUKUNGAN MULTI-BAHASA (ID/EN) Terintegrasi
// =====================================================
let testimoniSwiper = null;
let allTestimoniData = []; // Simpan data di memori agar tidak perlu loading ulang saat ganti bahasa
// 1. Variabel penampung bahasa yang terhubung otomatis dengan tombol ID/EN
let activeCarouselLang = localStorage.getItem('if_untan_lang') || document.documentElement.lang || 'id';
// 2. Tangkap event perubahan bahasa secara langsung dari localization.js
document.addEventListener('languageChanged', (e) => {
// Update bahasa
if (e.detail && e.detail.lang) {
activeCarouselLang = e.detail.lang;
} else if (typeof window.getLang === 'function') {
activeCarouselLang = window.getLang();
} else {
activeCarouselLang = document.documentElement.lang || 'id';
}
// Jika data sudah ada, langsung cetak ulang testimoninya
if (allTestimoniData && allTestimoniData.length > 0) {
renderTestimoniSlides(allTestimoniData);
initSwiper(allTestimoniData.length); // Swiper harus di-restart agar animasinya tidak rusak
}
});
/** Render slide testimoni ke dalam swiper-wrapper */
function renderTestimoniSlides(data) {
const wrapper = document.getElementById('testimonial-wrapper');
if (!wrapper) return;
const lang = activeCarouselLang; // Gunakan bahasa terbaru
wrapper.innerHTML = data.map(t => {
// Logika Pintar: Cek mode bahasa. Jika 'en' dan teks terjemahannya ada, pakai bahasa Inggris.
// Jika tidak, tetap tampilkan teks bahasa Indonesia (sebagai cadangan).
const beasiswaName = (lang === 'en' && t.nama_beasiswa_en) ? t.nama_beasiswa_en : t.nama_beasiswa;
const quote = (lang === 'en' && t.kutipan_en) ? t.kutipan_en : t.kutipan;
return `
<div class="swiper-slide" style="height:auto;">
<div class="testimonial-card">
<img
src="${t.foto}"
alt="Foto ${t.nama}"
class="testimonial-avatar"
loading="lazy"
onerror="
this.style.display='none';
this.nextElementSibling.style.display='flex';
"
>
<div
class="testimonial-avatar"
style="
display:none;
background:linear-gradient(135deg,#003150,#4a6bc5);
align-items:center;
justify-content:center;
color:white;
font-size:22px;
font-weight:700;
"
>
${t.nama.charAt(0).toUpperCase()}
</div>
<div class="testimonial-name">${t.nama}</div>
<div class="testimonial-beasiswa">${beasiswaName}</div>
<p class="testimonial-quote">"${quote}"</p>
</div>
</div>
`;
}).join('');
}
/** Inisialisasi Swiper setelah slide di-render */
function initSwiper(count) {
// Bersihkan swiper lama jika ada (penting agar tidak menumpuk saat ganti bahasa)
if (testimoniSwiper) {
testimoniSwiper.destroy(true, true);
testimoniSwiper = null;
}
testimoniSwiper = new Swiper('.testimonial-swiper', {
slidesPerView: 1,
spaceBetween: 24,
loop: count > 3,
centerInsufficientSlides: true,
autoplay: {
delay: 4500,
disableOnInteraction: false,
pauseOnMouseEnter: true,
},
pagination: {
el: '.testimonial-pagination',
clickable: true,
},
grabCursor: true,
breakpoints: {
640: { slidesPerView: 2 },
1024: { slidesPerView: 3 },
},
// Dukungan Aksesibilitas Dinamis (VoiceOver / Screen Reader)
a11y: {
prevSlideMessage: activeCarouselLang === 'en' ? 'Previous slide' : 'Slide sebelumnya',
nextSlideMessage: activeCarouselLang === 'en' ? 'Next slide' : 'Slide berikutnya',
},
});
}
/** Fetch data testimoni dan render */
async function initCarousel() {
const section = document.getElementById('testimonial');
try {
const res = await fetch('data/testimoni.json');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
allTestimoniData = data; // Simpan ke variabel global
if (!data || data.length === 0) {
section?.classList.add('hidden');
return;
}
renderTestimoniSlides(data);
requestAnimationFrame(() => initSwiper(data.length));
} catch {
section?.classList.add('hidden');
}
}
document.addEventListener('DOMContentLoaded', initCarousel);
+53
View File
@@ -0,0 +1,53 @@
// =====================================================
// counter.js — Animasi Counter Statistik
// Menggunakan IntersectionObserver + requestAnimationFrame
// =====================================================
/**
* Animasi angka dari 0 ke nilai target
* @param {HTMLElement} el - Elemen yang menampilkan angka
* @param {number} target - Angka target
* @param {number} duration - Durasi animasi dalam ms
*/
function animateCounter(el, target, duration = 1800) {
const startTime = performance.now();
function update(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// Ease out cubic — percepat di awal, lambat di akhir
const eased = 1 - Math.pow(1 - progress, 3);
const current = Math.round(eased * target);
el.textContent = current;
if (progress < 1) {
requestAnimationFrame(update);
}
}
requestAnimationFrame(update);
}
function initCounters() {
const counters = document.querySelectorAll('[data-counter]');
if (!counters.length) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !entry.target.dataset.animated) {
entry.target.dataset.animated = 'true';
const target = parseInt(entry.target.dataset.counter, 10) || 0;
animateCounter(entry.target, target);
}
});
},
{ threshold: 0.6 }
);
counters.forEach(el => observer.observe(el));
}
document.addEventListener('DOMContentLoaded', initCounters);
+288
View File
@@ -0,0 +1,288 @@
// =====================================================
// filter.js — Fetch JSON, Render Kartu, Filter Tab, Pencarian, Paginasi
// Filter dan search berjalan KUMULATIF
// + DUKUNGAN MULTI-BAHASA (ID/EN) Terintegrasi
// =====================================================
const ITEMS_PER_PAGE = 6;
let allData = [];
let activeFilter = 'semua';
let searchKeyword = '';
let currentPage = 1;
// 1. Variabel penampung bahasa yang terhubung otomatis dengan tombol ID/EN
let activeLang = localStorage.getItem('if_untan_lang') || document.documentElement.lang || 'id';
// 2. Tangkap event perubahan bahasa secara langsung dari localization.js
document.addEventListener('languageChanged', (e) => {
if (e.detail && e.detail.lang) {
activeLang = e.detail.lang; // Perbarui bahasa!
} else {
activeLang = document.documentElement.lang || 'id';
}
// Jika data sudah ada, langsung cetak ulang kartunya
if (allData.length > 0) {
renderCards();
}
});
// 3. Format tanggal cerdas berdasarkan bahasa
function formatDate(str) {
if (!str) return '—';
const d = new Date(str);
if (isNaN(d.getTime())) return str;
return d.toLocaleDateString(activeLang === 'en' ? 'en-US' : 'id-ID', {
day: 'numeric', month: 'long', year: 'numeric'
});
}
// 4. Kamus mini untuk teks dinamis dari JSON (Negara & Kategori)
function translateDynamic(text, lang) {
if (!text || lang === 'id') return text;
const dict = {
'Amerika Serikat': 'United States',
'Swiss': 'Switzerland',
'Pemerintah': 'Government',
'Swasta': 'Private',
};
return dict[text] || text;
}
function sanitize(str) {
return str.replace(/[<>&"']/g, c =>
({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;' }[c])
);
}
function createCard(bsw) {
const lang = activeLang; // Selalu gunakan bahasa aktif terbaru
// Terjemahan Tombol Status
let statusClass = 'status-btn-ditutup';
let statusLabel = lang === 'en' ? 'Closed' : 'Ditutup';
const statusLower = (bsw.status || '').toLowerCase();
if (statusLower === 'buka' || statusLower === 'dibuka') {
statusClass = 'status-btn-buka';
statusLabel = lang === 'en' ? 'Registration Open' : 'Pendaftaran Dibuka';
} else if (statusLower.includes('segera')) {
statusClass = 'status-btn-segera';
statusLabel = lang === 'en' ? 'Opening Soon' : 'Segera Dibuka';
}
// --- KAMUS MINI UNTUK KARTU ---
const textMulai = lang === 'en' ? 'Start' : 'Mulai';
const textDeadline = lang === 'en' ? 'Deadline' : 'Tenggat';
const textSelengkapnya = lang === 'en' ? 'Read More' : 'Selengkapnya';
// Ambil pills (tags) dan terjemahkan negaranya
const tags = Array.isArray(bsw.tags) ? bsw.tags : [];
const jenjang = tags[0] || '';
const skipPatterns = /^(S1|S2|S3|D3|D4|Swasta|Pemerintah|Fully Funded|Partially Funded|Beasiswa Penuh|Beasiswa Sebagian|Prestasi)$/i;
const rawNegara = tags.slice(1).find(t => !skipPatterns.test(t)) || '';
const negara = translateDynamic(rawNegara, lang);
// Terjemahan Subtitle (Kategori Beasiswa)
const tipe = bsw.kategori || '';
const rawTipe = tipe.replace(/^beasiswa\s+/i, '');
let subtitle = '';
if (rawTipe) {
if (lang === 'en') {
const tipeEn = translateDynamic(rawTipe, lang);
subtitle = `${tipeEn} Scholarship`;
} else {
subtitle = `Beasiswa ${rawTipe}`;
}
}
// Ambil nama dari versi Inggris jika ada
const namaBsw = (lang === 'en' && bsw.nama_en) ? bsw.nama_en : bsw.nama;
const pillsHtml = [jenjang, negara]
.filter(Boolean)
.map((t, i) => `<span class="card-pill ${i === 0 ? 'card-pill-level' : ''}">${t}</span>`)
.join('');
const article = document.createElement('article');
article.className = 'scholarship-card fade-in-up';
article.setAttribute('role', 'listitem');
article.setAttribute('data-id', bsw.id);
article.innerHTML = `
<div class="card-body">
<h3 class="card-title">${namaBsw}</h3>
${subtitle ? `<p class="card-subtitle">${subtitle}</p>` : ''}
${pillsHtml ? `<div class="card-pills">${pillsHtml}</div>` : ''}
<hr class="card-divider">
<div class="card-dates">
<div class="card-date-col">
<span class="card-date-label">${textMulai}</span>
<span class="card-date-value">${formatDate(bsw.tanggal_buka)}</span>
</div>
<div class="card-date-col">
<span class="card-date-label">${textDeadline}</span>
<span class="card-date-value">${formatDate(bsw.tanggal_tutup)}</span>
</div>
</div>
<button class="card-status-btn ${statusClass}" disabled>${statusLabel}</button>
<button
class="btn-selengkapnya"
onclick="openPopup('${bsw.id}')"
aria-label="Lihat detail beasiswa ${namaBsw}"
>${textSelengkapnya}</button>
</div>
`;
return article;
}
function getFiltered() {
const kw = searchKeyword.toLowerCase();
return allData.filter(bsw => {
// Logika Filter Tab S1 & Studi Lanjut
let matchFilter = false;
const tags = bsw.tags || [];
if (activeFilter === 'semua') {
matchFilter = true;
} else if (activeFilter === 's1') {
matchFilter = tags.some(t => t.includes('S1'));
} else if (activeFilter === 'studi_lanjut') {
matchFilter = tags.some(t => t.includes('S2') || t.includes('S3'));
}
// Logika Pencarian Search Bar
const matchSearch = !kw ||
bsw.nama.toLowerCase().includes(kw) ||
bsw.deskripsi_singkat.toLowerCase().includes(kw) ||
tags.some(t => t.toLowerCase().includes(kw));
return matchFilter && matchSearch;
});
}
function renderCards() {
const grid = document.getElementById('beasiswa-grid');
const pagCont = document.getElementById('pagination');
const filtered = getFiltered();
const lang = activeLang; // Gunakan bahasa terbaru
grid.innerHTML = '';
if (filtered.length === 0) {
const emptyTitle = lang === 'en' ? 'No scholarships found' : 'Tidak ada beasiswa ditemukan';
const emptyDesc = lang === 'en' ? 'Try adjusting your filters or search keywords' : 'Coba ubah filter atau kata kunci pencarian Anda';
grid.innerHTML = `
<div class="empty-state" style="grid-column:1/-1">
<i class="fas fa-search-minus"></i>
<p style="font-size:15px;font-weight:700;color:#94a3b8;margin-bottom:6px;">${emptyTitle}</p>
<p style="font-size:13px;color:#cbd5e1;">${emptyDesc}</p>
</div>`;
pagCont.innerHTML = '';
return;
}
const totalPages = Math.ceil(filtered.length / ITEMS_PER_PAGE);
if (currentPage > totalPages) currentPage = totalPages;
const start = (currentPage - 1) * ITEMS_PER_PAGE;
const items = filtered.slice(start, start + ITEMS_PER_PAGE);
items.forEach((bsw, i) => {
const card = createCard(bsw);
card.style.animationDelay = `${i * 55}ms`;
grid.appendChild(card);
});
renderPagination(totalPages, pagCont);
}
function renderPagination(total, container) {
container.innerHTML = '';
if (total <= 1) return;
const make = (content, page, disabled = false, isActive = false) => {
const btn = document.createElement('button');
btn.className = `page-btn${isActive ? ' active' : ''}`;
btn.innerHTML = content;
btn.disabled = disabled;
if (!disabled) {
btn.addEventListener('click', () => {
currentPage = page;
renderCards();
const dir = document.getElementById('directory');
if (dir) {
const top = dir.getBoundingClientRect().top + window.scrollY - 120;
window.scrollTo({ top, behavior: 'smooth' });
}
});
}
return btn;
};
container.appendChild(make('<i class="fas fa-chevron-left" style="font-size:11px;"></i>', currentPage - 1, currentPage === 1));
for (let i = 1; i <= total; i++) {
container.appendChild(make(i, i, false, i === currentPage));
}
container.appendChild(make('<i class="fas fa-chevron-right" style="font-size:11px;"></i>', currentPage + 1, currentPage === total));
}
function initFilterTabs() {
document.querySelectorAll('.filter-tab').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-tab').forEach(b => {
b.classList.remove('active');
b.setAttribute('aria-selected', 'false');
});
btn.classList.add('active');
btn.setAttribute('aria-selected', 'true');
activeFilter = btn.dataset.filter;
currentPage = 1;
renderCards();
});
});
}
function initSearch() {
const input = document.getElementById('search-input');
if (!input) return;
input.addEventListener('input', () => {
searchKeyword = sanitize(input.value).trim();
currentPage = 1;
renderCards();
});
}
async function initDirectory() {
const grid = document.getElementById('beasiswa-grid');
try {
if (!window.BeasiswaAPI) throw new Error('Modul API.js (BeasiswaAPI) belum dimuat');
const { data, source } = await window.BeasiswaAPI.getBeasiswa();
allData = data;
window.beasiswaData = allData;
renderCards();
} catch (err) {
const lang = activeLang;
const errTitle = lang === 'en' ? 'Failed to load scholarship data' : 'Gagal memuat data beasiswa';
const errDesc = lang === 'en' ? 'Could not connect to the server. Please refresh or contact admin.' : 'Tidak dapat terhubung ke server. Silakan refresh atau hubungi admin.';
grid.innerHTML = `
<div class="empty-state" style="grid-column:1/-1">
<i class="fas fa-exclamation-triangle" style="color:#fca5a5;"></i>
<p style="font-size:15px;font-weight:700;color:#94a3b8;margin-bottom:6px;">${errTitle}</p>
<p style="font-size:13px;color:#cbd5e1;">${errDesc}</p>
</div>`;
}
}
document.addEventListener('DOMContentLoaded', () => {
initFilterTabs();
initSearch();
initDirectory();
});
+173
View File
@@ -0,0 +1,173 @@
// =====================================================
// main.js — Inisialisasi Utama
// Sticky Navbar, Hamburger Menu, Smooth Scroll, AOS, Active Nav
// =====================================================
document.addEventListener('DOMContentLoaded', () => {
// ─── AOS (Animate on Scroll) ────────────────────────
AOS.init({
duration: 700,
easing: 'ease-out-cubic',
once: true,
offset: 50,
});
// ─── Sticky Navbar ──────────────────────────────────
// Navbar sudah fixed di CSS; tidak perlu mengubah warna karena
// warna primary sudah menjadi identitas halaman ini.
// Namun kita tambahkan shadow saat scroll untuk depth effect.
// PERBAIKAN: Menyesuaikan ID dengan yang ada di index.html (nav-wrapper)
const navbar = document.getElementById('nav-wrapper') || document.getElementById('navbar');
const handleScroll = () => {
if (!navbar) return; // PERBAIKAN: Mencegah error jika elemen tidak ada di halaman
if (window.scrollY > 10) {
navbar.style.boxShadow = '0 4px 20px rgba(0,0,0,0.25)';
} else {
navbar.style.boxShadow = 'none';
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
handleScroll(); // initial call
// ─── Ticker Visibility (Muncul setelah Hero & Stats) ─
const ticker = document.querySelector('.ticker-wrapper');
const statsSection = document.getElementById('statistics');
const handleTickerVisibility = () => {
if (!ticker || !statsSection) return;
// Ticker muncul setelah melewati batas bawah elemen statistik
const statsBottom = statsSection.offsetTop + statsSection.offsetHeight;
if (window.scrollY > statsBottom) {
ticker.classList.add('show-ticker');
} else {
ticker.classList.remove('show-ticker');
}
};
window.addEventListener('scroll', handleTickerVisibility, { passive: true });
// Call once on load with a slight delay to ensure layout is ready
setTimeout(handleTickerVisibility, 100);
// ─── Hamburger Menu (Mobile) ─────────────────────────
const mobileBtn = document.getElementById('mobile-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
const closeMobileBtn = document.getElementById('close-mobile-menu');
const closeMobileMenu = () => {
mobileMenu?.classList.add('-translate-x-full');
mobileBtn?.setAttribute('aria-expanded', 'false');
};
// openMobileMenu handled by inline script in HTML; this covers close-on-scroll
closeMobileBtn?.addEventListener('click', closeMobileMenu);
// ─── Smooth Scroll untuk anchor links ──────────────
// Sesuai SDD: CSS scroll-behavior + JS fallback dengan offset
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', e => {
const href = anchor.getAttribute('href');
const target = document.querySelector(href);
if (!target) return;
e.preventDefault();
// PERBAIKAN: Mencegah error saat membaca offsetHeight jika navbar null
const navHeight = navbar ? navbar.offsetHeight : 110;
const top = target.getBoundingClientRect().top + window.scrollY - navHeight - 4;
window.scrollTo({ top, behavior: 'smooth' });
// Tutup mobile menu jika sedang terbuka
closeMobileMenu();
});
});
// ─── Active Nav Link via IntersectionObserver ──────
// Sesuai SDD fitur #4: highlight menu sesuai seksi yang terlihat
const sections = document.querySelectorAll('section[id]');
const navLinks = document.querySelectorAll('a.nav-link');
const sectionObserver = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
navLinks.forEach(link => {
link.classList.remove('active');
if (link.getAttribute('href') === `#${entry.target.id}`) {
link.classList.add('active');
}
});
}
});
},
{ rootMargin: '-30% 0px -65% 0px' }
);
sections.forEach(s => sectionObserver.observe(s));
// ─── Back to Top visibility ────────────────────────
const backToTop = document.getElementById('back-to-top');
if (backToTop) {
window.addEventListener('scroll', () => {
if (window.scrollY > 400) {
backToTop.classList.remove('opacity-0', 'pointer-events-none');
backToTop.classList.add('opacity-100');
} else {
backToTop.classList.add('opacity-0', 'pointer-events-none');
backToTop.classList.remove('opacity-100');
}
}, { passive: true });
backToTop.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}
// ─── Contact Us Modal (Hubungi Kami) ────────────────
const btnOpenContact = document.getElementById('btn-open-contact');
const contactOverlay = document.getElementById('contact-overlay');
const contactModal = document.getElementById('contact-modal');
const contactClose = document.getElementById('contact-close');
const contactBackdrop = document.getElementById('contact-backdrop');
const openContactModal = () => {
if (!contactOverlay) return;
contactOverlay.classList.remove('hidden');
contactOverlay.classList.add('flex');
// Animasi muncul
setTimeout(() => {
contactOverlay.classList.remove('opacity-0');
contactModal.classList.remove('scale-95');
contactModal.classList.add('scale-100');
}, 10);
};
const closeContactModal = () => {
if (!contactOverlay) return;
contactOverlay.classList.add('opacity-0');
contactModal.classList.remove('scale-100');
contactModal.classList.add('scale-95');
// Tunggu animasi selesai baru di-hide
setTimeout(() => {
contactOverlay.classList.remove('flex');
contactOverlay.classList.add('hidden');
}, 300);
};
if (btnOpenContact) btnOpenContact.addEventListener('click', openContactModal);
if (contactClose) contactClose.addEventListener('click', closeContactModal);
if (contactBackdrop) contactBackdrop.addEventListener('click', closeContactModal);
}); // end DOMContentLoaded
+202
View File
@@ -0,0 +1,202 @@
// =====================================================
// popup.js — Modal Detail Beasiswa
// Tutup via: tombol ×, klik overlay, tekan Escape
// + DUKUNGAN MULTI-BAHASA (ID/EN)
// =====================================================
const overlay = document.getElementById('popup-overlay');
const modal = document.getElementById('popup-modal');
const closeBtn = document.getElementById('popup-close');
const popupBody = document.getElementById('popup-content');
let currentOpenId = null; // Menyimpan status beasiswa mana yang sedang dibuka
// Bertanya langsung ke mesin utama (localization.js)
function getPopupLang() {
if (typeof window.getLang === 'function') {
return window.getLang();
}
return localStorage.getItem('if_untan_lang') || 'id';
}
// Format tanggal dinamis (ID / EN)
function fmtDate(str, lang) {
if (!str) return '—';
const d = new Date(str);
if (isNaN(d.getTime())) return str;
return d.toLocaleDateString(lang === 'en' ? 'en-US' : 'id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
}
// Kamus Teks Statis Modal
const popupDict = {
id: {
req_empty: 'Syarat belum tersedia. Kunjungi sumber resmi.',
req_title: 'Syarat Umum',
open_date: 'Tanggal Buka',
close_date: 'Tanggal Tutup',
contact: 'Kontak',
more_info: 'Info Lebih Lanjut',
no_link: 'Tautan tidak tersedia. Silakan hubungi admin prodi.',
status_open: 'Buka',
status_closed: 'Ditutup'
},
en: {
req_empty: 'Requirements not available. Visit official source.',
req_title: 'General Requirements',
open_date: 'Open Date',
close_date: 'Closing Date',
contact: 'Contact',
more_info: 'More Information',
no_link: 'Link unavailable. Please contact department admin.',
status_open: 'Open',
status_closed: 'Closed'
}
};
/**
* Buka popup modal untuk beasiswa dengan ID tertentu
*/
function openPopup(id) {
currentOpenId = id;
const data = window.beasiswaData || [];
const bsw = data.find(b => b.id === id);
if (!bsw) return;
const lang = getPopupLang();
const d = popupDict[lang];
const isBuka = bsw.status === 'Buka' || bsw.status === 'Dibuka';
const badgeClass = isBuka ? 'badge-buka' : 'badge-ditutup';
// Terjemahkan Status & Kategori
const statusText = isBuka ? d.status_open : (lang === 'en' && (bsw.status === 'Ditutup' || bsw.status === 'Tutup') ? d.status_closed : bsw.status);
// Menerjemahkan Kategori (Misal: "Beasiswa Swasta" -> "Private Scholarship")
let kategoriText = bsw.kategori;
if (lang === 'en') {
if (bsw.kategori_en) {
kategoriText = bsw.kategori_en;
} else if (bsw.kategori.toLowerCase().includes('swasta')) {
kategoriText = 'Private Scholarship';
} else if (bsw.kategori.toLowerCase().includes('pemerintah')) {
kategoriText = 'Government Scholarship';
}
}
// Teks Dinamis (Ambil _en dari JSON jika ada, jika tidak pakai ID)
const namaText = (lang === 'en' && bsw.nama_en) ? bsw.nama_en : bsw.nama;
const deskripsiText = (lang === 'en' && bsw.deskripsi_singkat_en) ? bsw.deskripsi_singkat_en : bsw.deskripsi_singkat;
const syaratData = (lang === 'en' && bsw.syarat_umum_en) ? bsw.syarat_umum_en : (bsw.syarat_umum || []);
// Bangun list syarat umum
const syaratItems = syaratData
.map(s => `
<li>
<i class="fas fa-check-circle"></i>
<span>${s}</span>
</li>`)
.join('');
const syaratHTML = syaratItems
? `<ul class="syarat-list">${syaratItems}</ul>`
: `<p style="font-size:13px;color:#94a3b8;font-style:italic;">${d.req_empty}</p>`;
const hasUrl = bsw.url_sumber && bsw.url_sumber !== '#';
// Render konten popup dengan template Anda yang dipertahankan
popupBody.innerHTML = `
<div class="popup-body">
<div class="popup-kategori-row">
<span class="badge-status ${badgeClass}">${statusText}</span>
<span class="popup-kategori">${kategoriText}</span>
</div>
<h2 id="popup-title" class="popup-title">${namaText}</h2>
<p class="popup-desc">${deskripsiText}</p>
<div class="popup-section-title">
<i class="fas fa-list-check"></i>
${d.req_title}
</div>
${syaratHTML}
<div class="popup-periode">
<div class="popup-periode-item">
<label>${d.open_date}</label>
<span>${fmtDate(bsw.tanggal_buka, lang)}</span>
</div>
<div class="popup-periode-item">
<label>${d.close_date}</label>
<span style="${!isBuka ? 'color:#EF4444;' : ''}">${fmtDate(bsw.tanggal_tutup, lang)}</span>
</div>
</div>
${bsw.kontak ? `
<div class="popup-kontak">
<i class="fas fa-phone-alt"></i>
<div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;margin-bottom:2px;">${d.contact}</div>
${bsw.kontak}
</div>
</div>` : ''}
${hasUrl
? `<a
href="${bsw.url_sumber}"
target="_blank"
rel="noopener noreferrer"
class="popup-cta"
aria-label="${d.more_info} ${bsw.nama}"
>
${d.more_info}
<i class="fas fa-external-link-alt" style="font-size:12px;"></i>
</a>`
: `<button class="popup-cta disabled" disabled aria-disabled="true">
${d.more_info}
<i class="fas fa-external-link-alt" style="font-size:12px;"></i>
</button>
<p style="text-align:center;font-size:11px;color:#94a3b8;margin-top:8px;">
${d.no_link}
</p>`
}
</div>
`;
// Tampilkan overlay
overlay.classList.add('show');
document.body.style.overflow = 'hidden';
// Set focus ke modal untuk aksesibilitas
setTimeout(() => modal.focus(), 50);
}
/** Tutup popup modal */
function closePopup() {
currentOpenId = null;
overlay.classList.remove('show');
document.body.style.overflow = '';
}
// === Event Listeners untuk menutup popup ===
closeBtn?.addEventListener('click', closePopup);
document.getElementById('popup-backdrop')?.addEventListener('click', closePopup);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && overlay.classList.contains('show')) {
closePopup();
}
});
// Jika bahasa diganti SAAT popup sedang terbuka, cetak ulang isinya!
document.addEventListener('languageChanged', () => {
if (currentOpenId && overlay.classList.contains('show')) {
openPopup(currentOpenId);
}
});
window.openPopup = openPopup;
window.closePopup = closePopup;
+285
View File
@@ -0,0 +1,285 @@
[
{
"id": "bsw-001",
"nama": "Boustany Foundation Scholarship, MBA - Harvard University",
"nama_en": "Boustany Foundation Scholarship, MBA - Harvard University",
"kategori": "Swasta",
"kategori_en": "Private",
"status": "Buka",
"thumbnail": "https://images.unsplash.com/photo-1541339907198-e08756dedf3f?w=400&q=80&auto=format&fit=crop",
"deskripsi_singkat": "Beasiswa bergengsi dari Boustany Foundation untuk program MBA di Harvard University, Amerika Serikat, bagi calon pemimpin bisnis dunia.",
"deskripsi_singkat_en": "A prestigious scholarship from the Boustany Foundation for the MBA program at Harvard University, United States, for future global business leaders.",
"syarat_umum": [
"Diterima atau mendaftar di program MBA Harvard Business School",
"Memiliki rekam jejak prestasi akademik dan profesional yang luar biasa",
"Menunjukkan potensi kepemimpinan dan kontribusi internasional",
"Melengkapi aplikasi beasiswa secara online melalui situs resmi Boustany Foundation"
],
"syarat_umum_en": [
"Accepted or applying to the MBA program at Harvard Business School",
"Have an exceptional track record of academic and professional achievements",
"Demonstrate leadership potential and international contribution",
"Complete the scholarship application online through the official Boustany Foundation website"
],
"kontak": "Boustany Foundation — boustany-foundation.com",
"tanggal_buka": "2026-05-21",
"tanggal_tutup": "2027-05-31",
"url_sumber": "https://www.schoters.com/id/beasiswa/boustany-foundation-scholarship-mba-harvard-university-1",
"tags": ["S2", "Swasta", "Amerika Serikat", "Partially Funded", "MBA"]
},
{
"id": "bsw-002",
"nama": "La Trobe University - La Trobe High Achiever Scholarship (S1)",
"kategori": "Swasta",
"kategori_en": "Private",
"status": "Buka",
"thumbnail": "https://images.unsplash.com/photo-1523240795612-9a054b0db644?w=400&q=80&auto=format&fit=crop",
"deskripsi_singkat": "Beasiswa dari La Trobe University Australia untuk mahasiswa internasional jenjang S1 berprestasi tinggi yang ingin melanjutkan studi di Melbourne.",
"deskripsi_singkat_en": "Scholarship from La Trobe University Australia for high-achieving international undergraduate students who wish to pursue their studies in Melbourne.",
"syarat_umum": [
"Mendaftar sebagai mahasiswa internasional jenjang S1 (Undergraduate) di La Trobe University",
"Memiliki nilai akademik yang sangat baik (High Achiever)",
"Tidak sedang menerima beasiswa penuh lainnya dari La Trobe University",
"Memenuhi persyaratan masuk dan kemampuan bahasa Inggris La Trobe University"
],
"syarat_umum_en": [
"Apply as an international undergraduate student at La Trobe University",
"Have an excellent academic record (High Achiever)",
"Not currently receiving any other full scholarship from La Trobe University",
"Meet La Trobe University's entry and English language requirements"
],
"kontak": "La Trobe University Admissions — latrobe.edu.au",
"tanggal_buka": "2023-09-29",
"tanggal_tutup": "2026-12-31",
"url_sumber": "https://www.schoters.com/id/beasiswa/la-trobe-university-la-trobe-high-achiever-scholarship-2",
"tags": ["S1", "Swasta", "Australia", "Partially Funded"]
},
{
"id": "bsw-003",
"nama": "Charles Sturt University - International Student Support Scholarship",
"kategori": "Swasta",
"kategori_en": "Private",
"status": "Buka",
"thumbnail": "https://images.unsplash.com/photo-1560472354-b33ff0c44a43?w=400&q=80&auto=format&fit=crop",
"deskripsi_singkat": "Beasiswa dukungan dari Charles Sturt University bagi mahasiswa internasional jenjang S1 yang mendaftar di kampus yang berlokasi di New South Wales, Australia.",
"deskripsi_singkat_en": "Support scholarship from Charles Sturt University for international undergraduate students enrolling at a campus located in New South Wales, Australia.",
"syarat_umum": [
"Mendaftar sebagai mahasiswa internasional penuh waktu (full-time) jenjang S1",
"Memenuhi persyaratan penerimaan akademik Charles Sturt University",
"Memiliki kemampuan Bahasa Inggris yang memadai (IELTS/TOEFL)",
"Mendaftar melalui jalur penerimaan internasional resmi"
],
"syarat_umum_en": [
"Apply as a full-time international undergraduate student",
"Meet Charles Sturt University's academic admission requirements",
"Have adequate English language proficiency (IELTS/TOEFL)",
"Apply through the official international admission pathway"
],
"kontak": "Charles Sturt University International — csu.edu.au",
"tanggal_buka": "2026-05-21",
"tanggal_tutup": "2026-12-31",
"url_sumber": "https://www.schoters.com/id/beasiswa/charles-sturt-university-international-student-support-scholarship-3",
"tags": ["S1", "Swasta", "Australia", "Partially Funded"]
},
{
"id": "bsw-004",
"nama": "La Trobe University - La Trobe High Achiever Scholarship (S2)",
"kategori": "Swasta",
"kategori_en": "Private",
"status": "Buka",
"thumbnail": "https://images.unsplash.com/photo-1529156069898-49953e39b3ac?w=400&q=80&auto=format&fit=crop",
"deskripsi_singkat": "Beasiswa dari La Trobe University Australia untuk mahasiswa internasional jenjang S2 (Postgraduate) dengan pencapaian akademik tinggi.",
"deskripsi_singkat_en": "Scholarship from La Trobe University Australia for international postgraduate students with high academic achievements.",
"syarat_umum": [
"Mendaftar sebagai mahasiswa internasional jenjang S2 di La Trobe University",
"Memiliki nilai akademik yang sangat baik di jenjang sarjana sebelumnya",
"Memenuhi persyaratan bahasa Inggris yang ditetapkan",
"Tidak sedang menerima beasiswa lain dari universitas yang sama"
],
"syarat_umum_en": [
"Apply as an international postgraduate student at La Trobe University",
"Have excellent academic grades in the previous undergraduate degree",
"Meet the specified English language requirements",
"Not currently receiving another scholarship from the same university"
],
"kontak": "La Trobe University Admissions — latrobe.edu.au",
"tanggal_buka": "2023-09-29",
"tanggal_tutup": "2026-12-31",
"url_sumber": "https://www.schoters.com/id/beasiswa/la-trobe-university-la-trobe-high-achiever-scholarship-4",
"tags": ["S2", "Swasta", "Australia", "Partially Funded"]
},
{
"id": "bsw-005",
"nama": "ETH Zurich - Excellence Scholarship & Opportunity Programme (ESOP)",
"kategori": "Pemerintah",
"kategori_en": "Government",
"status": "Buka",
"thumbnail": "https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=400&q=80&auto=format&fit=crop",
"deskripsi_singkat": "Beasiswa bergengsi dari ETH Zurich (salah satu universitas teknik terbaik dunia) untuk mahasiswa S2 internasional yang unggul secara akademik.",
"deskripsi_singkat_en": "Prestigious scholarship from ETH Zurich (one of the world's best engineering universities) for academically outstanding international master's students.",
"syarat_umum": [
"Mendaftar program Master (S2) di ETH Zurich",
"Lulus ujian penerimaan ETH Zurich atau memiliki setara gelar sarjana yang diakui",
"Menunjukkan prestasi akademik luar biasa (biasanya masuk 10% terbaik angkatan)",
"Melengkapi formulir aplikasi beasiswa ESOP yang terpisah dari aplikasi studi",
"Mendapatkan surat rekomendasi dari akademisi berwenang"
],
"syarat_umum_en": [
"Apply for a Master's program at ETH Zurich",
"Pass the ETH Zurich entrance exam or hold a recognized equivalent bachelor's degree",
"Demonstrate outstanding academic performance (usually top 10% of the cohort)",
"Complete the ESOP scholarship application form separately from the study application",
"Obtain a recommendation letter from authorized academics"
],
"kontak": "ETH Zurich Scholarship Office — ethz.ch",
"tanggal_buka": "2026-11-01",
"tanggal_tutup": "2026-11-30",
"url_sumber": "https://www.schoters.com/id/beasiswa/eth-zurich-excellence-scholarship-opportunity-programme-esop-5",
"tags": ["S2", "Pemerintah", "Swiss", "Fully Funded", "Prestasi"]
},
{
"id": "bsw-006",
"nama": "STUST - Scholarship for International Students (S3, Fully Funded)",
"kategori": "Swasta",
"kategori_en": "Private",
"status": "Buka",
"thumbnail": "https://images.unsplash.com/photo-1580582932707-520aed937b7b?w=400&q=80&auto=format&fit=crop",
"deskripsi_singkat": "Beasiswa penuh (fully funded) dari Southern Taiwan University of Science and Technology untuk mahasiswa internasional jenjang S3 (Doktoral).",
"deskripsi_singkat_en": "Fully funded scholarship from Southern Taiwan University of Science and Technology for international doctoral students.",
"syarat_umum": [
"Mendaftar program Doktoral (S3) di STUST, Taiwan",
"Memiliki gelar magister (S2) yang diakui",
"Memenuhi persyaratan kemampuan bahasa Inggris atau Mandarin",
"Menyerahkan proposal penelitian yang relevan dengan bidang studi yang dipilih",
"Mendapatkan persetujuan dari calon pembimbing (supervisor) di STUST"
],
"syarat_umum_en": [
"Apply for a Doctoral program at STUST, Taiwan",
"Hold a recognized Master's degree",
"Meet the English or Mandarin language proficiency requirements",
"Submit a research proposal relevant to the chosen field of study",
"Obtain approval from a prospective supervisor at STUST"
],
"kontak": "STUST International Affairs Office — stust.edu.tw",
"tanggal_buka": "2026-09-15",
"tanggal_tutup": "2026-11-30",
"url_sumber": "https://www.schoters.com/id/beasiswa/southern-taiwan-university-of-science-and-technology-the-stust-scholarship-for-international-students-6",
"tags": ["S3", "Swasta", "Taiwan", "Fully Funded"]
},
{
"id": "bsw-007",
"nama": "STUST - Scholarship for International Students (S2, Partially Funded)",
"kategori": "Swasta",
"kategori_en": "Private",
"status": "Buka",
"thumbnail": "https://images.unsplash.com/photo-1606761568499-6d2451b23c66?w=400&q=80&auto=format&fit=crop",
"deskripsi_singkat": "Beasiswa parsial (partially funded) dari Southern Taiwan University of Science and Technology untuk mahasiswa internasional jenjang S2 (Magister).",
"deskripsi_singkat_en": "Partially funded scholarship from Southern Taiwan University of Science and Technology for international master's students.",
"syarat_umum": [
"Mendaftar program Magister (S2) di STUST, Taiwan",
"Memiliki gelar sarjana (S1) yang diakui",
"Memenuhi persyaratan kemampuan bahasa Inggris atau Mandarin",
"Melengkapi berkas aplikasi secara online melalui portal resmi STUST",
"Menyertakan transkrip nilai dan surat motivasi"
],
"syarat_umum_en": [
"Apply for a Master's program at STUST, Taiwan",
"Hold a recognized Bachelor's degree",
"Meet the English or Mandarin language proficiency requirements",
"Complete the application file online through the official STUST portal",
"Include academic transcripts and a motivation letter"
],
"kontak": "STUST International Affairs Office — stust.edu.tw",
"tanggal_buka": "2026-09-15",
"tanggal_tutup": "2026-11-30",
"url_sumber": "https://www.schoters.com/id/beasiswa/southern-taiwan-university-of-science-and-technology-the-stust-scholarship-for-international-students-7",
"tags": ["S2", "Swasta", "Taiwan", "Partially Funded"]
},
{
"id": "bsw-008",
"nama": "University of Melbourne - Melbourne International Undergraduate Scholarship",
"kategori": "Swasta",
"kategori_en": "Private",
"status": "Buka",
"thumbnail": "https://images.unsplash.com/photo-1504711434969-e33886168f5c?w=400&q=80&auto=format&fit=crop",
"deskripsi_singkat": "Beasiswa untuk mahasiswa internasional berprestasi yang mendaftar program S1 di University of Melbourne, salah satu universitas terbaik di Australia.",
"deskripsi_singkat_en": "Scholarship for outstanding international students applying for an undergraduate program at the University of Melbourne, one of Australia's best universities.",
"syarat_umum": [
"Merupakan mahasiswa internasional (bukan warga negara Australia atau New Zealand)",
"Mendaftar program sarjana (S1 / Undergraduate) di University of Melbourne",
"Memiliki hasil akademik yang sangat luar biasa di tingkat sekolah menengah atas",
"Memenuhi persyaratan kemampuan bahasa Inggris (IELTS minimal 6.5)",
"Beasiswa diberikan otomatis berdasarkan nilai akademik, tidak perlu aplikasi terpisah"
],
"syarat_umum_en": [
"Be an international student (not a citizen of Australia or New Zealand)",
"Apply for an undergraduate program at the University of Melbourne",
"Have an exceptional academic result at the high school level",
"Meet the English language proficiency requirements (minimum IELTS 6.5)",
"The scholarship is awarded automatically based on academic merit, no separate application required"
],
"kontak": "University of Melbourne Future Students — unimelb.edu.au",
"tanggal_buka": "2026-05-21",
"tanggal_tutup": "2026-10-31",
"url_sumber": "https://www.schoters.com/id/beasiswa/university-of-melbourne-melbourne-international-undergraduate-scholarship-8",
"tags": ["S1", "Swasta", "Australia", "Partially Funded", "Prestasi"]
},
{
"id": "bsw-009",
"nama": "Charles Darwin University - CDU Global Merit Scholarship",
"kategori": "Swasta",
"kategori_en": "Private",
"status": "Buka",
"thumbnail": "https://images.unsplash.com/photo-1434030216411-0b793f4b4173?w=400&q=80&auto=format&fit=crop",
"deskripsi_singkat": "Beasiswa merit global dari Charles Darwin University (CDU) di Darwin, Australia, untuk mahasiswa internasional S1 yang memiliki rekam jejak akademik yang kuat.",
"deskripsi_singkat_en": "Global merit scholarship from Charles Darwin University (CDU) in Darwin, Australia, for international undergraduate students with a strong academic track record.",
"syarat_umum": [
"Mendaftar sebagai mahasiswa internasional baru (new international student) di CDU",
"Mendaftar untuk program sarjana (Undergraduate/S1) penuh waktu",
"Memiliki nilai ujian akhir sekolah yang baik (dikonversi ke skala CDU)",
"Memenuhi persyaratan bahasa Inggris (IELTS 6.0 atau setara)",
"Tidak sedang menerima potongan biaya kuliah lainnya dari CDU"
],
"syarat_umum_en": [
"Apply as a new international student at CDU",
"Apply for a full-time undergraduate program",
"Have a good high school final exam score (converted to CDU scale)",
"Meet the English language requirements (IELTS 6.0 or equivalent)",
"Not currently receiving any other tuition fee discount from CDU"
],
"kontak": "CDU International — cdu.edu.au",
"tanggal_buka": "2026-05-21",
"tanggal_tutup": "2026-10-09",
"url_sumber": "https://www.schoters.com/id/beasiswa/charles-darwin-university-cdu-global-merit-scholarship-9",
"tags": ["S1", "Swasta", "Australia", "Partially Funded"]
},
{
"id": "bsw-010",
"nama": "Deakin University - Vice-Chancellor's International Scholarship",
"kategori": "Swasta",
"kategori_en": "Private",
"status": "Buka",
"thumbnail": "https://images.unsplash.com/photo-1541339907198-e08756dedf3f?w=400&q=80&auto=format&fit=crop",
"deskripsi_singkat": "Beasiswa bergengsi dari Wakil Rektor (Vice-Chancellor) Deakin University untuk mahasiswa internasional terbaik yang akan menempuh studi S1 atau S2 di Australia.",
"deskripsi_singkat_en": "Prestigious Vice-Chancellor's scholarship from Deakin University for the best international students pursuing an undergraduate or postgraduate degree in Australia.",
"syarat_umum": [
"Merupakan mahasiswa internasional (bukan warga negara Australia/New Zealand)",
"Mendaftar untuk program S1 (Undergraduate) atau S2 (Postgraduate Coursework) di Deakin",
"Memiliki rekam jejak akademik yang sangat baik dan konsisten",
"Memenuhi persyaratan masuk dan kemampuan bahasa Inggris Deakin University",
"Mengajukan aplikasi beasiswa sebelum tanggal penutupan yang ditetapkan"
],
"syarat_umum_en": [
"Be an international student (not an Australia/New Zealand citizen)",
"Apply for an undergraduate or postgraduate coursework program at Deakin",
"Have a consistent and excellent academic track record",
"Meet Deakin University's entry and English language requirements",
"Submit the scholarship application before the specified closing date"
],
"kontak": "Deakin University International — deakin.edu.au",
"tanggal_buka": "2026-05-21",
"tanggal_tutup": "2026-09-25",
"url_sumber": "https://www.schoters.com/id/beasiswa/deakin-university-vice-chancellor-s-international-scholarship-10",
"tags": ["S1 & S2", "S2", "Swasta", "Australia", "Partially Funded", "Prestasi"]
}
]
+11
View File
@@ -0,0 +1,11 @@
[
{
"id": "tsm-001",
"nama": "Violaine Gunawan",
"foto": "https://ui-avatars.com/api/?name=Violaine+Gunawan&background=111a2f&color=fff&size=200&rounded=true",
"nama_beasiswa": "BEASISWA BAKTI BCA 2026",
"nama_beasiswa_en": "BAKTI BCA SCHOLARSHIP 2026",
"kutipan": "Beasiswa ini sangat berdampak besar. Tantangannya menyeimbangkan kuliah, asisten praktikum, organisasi, dan projek besar beasiswa. Namun, relasi di kampus dan masyarakat jadi bertambah luas. Tips: Jangan takut mencoba, pahami fokus beasiswa, sesuaikan visi misi, dan aktiflah berorganisasi!",
"kutipan_en": "This scholarship has a massive impact. The challenge is balancing college, practicum assistant duties, organizations, and major scholarship projects. However, connections on campus and in society have expanded. Tip: Don't be afraid to try, understand the scholarship's focus, align your vision and mission, and be active in organizations!"
}
]
+305 -36
View File
@@ -1,15 +1,27 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="id" class="scroll-smooth">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Group 10 &ndash; Ape Name Tim E | Informatika UNTAN</title>
<title>Group 10 &ndash; ApeNameTeamE | Informatika UNTAN</title>
<link rel="stylesheet" href="../../assets/global/output.css">
<link rel="stylesheet" href="assets/css/style.css" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:ital,wght@0,100..900;1,100..900&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap" rel="stylesheet">
<link
href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:ital,wght@0,100..900;1,100..900&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
<!-- AOS.js -->
<link href="https://unpkg.com/aos@2.3.4/dist/aos.css" rel="stylesheet" />
<!-- Swiper.js -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.css" />
<style>
/* ===== MOBILE NAV STABILITY FIX =====
Prevents logo bar from collapsing/shifting on Android when
address bar hides/shows and triggers viewport reflow.
@@ -293,6 +305,7 @@
}
</style>
</head>
<body class="text-slate-800 antialiased bg-white selection:bg-untan-yellow selection:text-black">
<a href="#main-content" class="skip-link" data-i18n="skip_to_content">Langsung ke Konten Utama</a>
@@ -1139,18 +1152,262 @@
</div>
</div>
<main id="main-content" class="pt-[88px] lg:pt-[160px] min-h-screen bg-white">
<div class="container mx-auto px-4 lg:px-8 py-12">
<div class="mb-8 pb-8 border-b border-slate-200">
<span class="bg-untan-yellow text-slate-900 text-[10px] font-bold uppercase tracking-widest px-2 py-0.5 inline-block mb-3">Tugas Halaman Statis</span>
<h1 class="text-3xl lg:text-4xl font-bold text-slate-900 mb-2">Beasiswa</h1>
<p class="text-slate-500 text-sm">Kelompok 10 &mdash; Ape Name Tim E &nbsp;&middot;&nbsp; Informatika UNTAN 2026</p>
<main id="main-content" class="pt-[88px] lg:pt-[160px] min-h-screen bg-white">
<div class="ticker-wrapper" aria-label="Pengumuman beasiswa terbaru" role="marquee">
<!-- Badge block -->
<div class="ticker-badge-block">
<span class="ticker-badge" aria-hidden="true">INFO TERBARU</span>
</div>
<!-- Scroll area: inner wrapper berisi 2 copy teks, dianimasikan bersamaan -->
<div class="ticker-scroll-area">
<div class="ticker-inner" aria-live="off">
<span class="ticker-track">Selamat datang di antarmuka situs web Informatika terbaru!
&nbsp;|&nbsp; Pendaftaran
kolaborasi riset semester genap telah dibuka. &nbsp;|&nbsp; Pantau publikasi dosen dan
mahasiswa secara
real-time. &nbsp;|&nbsp; Beasiswa KIP Kuliah 2026 dibuka — Daftar sekarang!
&nbsp;|&nbsp; Beasiswa LPDP S2
tersedia hingga September 2026. &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
<span class="ticker-track" aria-hidden="true">Selamat datang di antarmuka situs web
Informatika terbaru!
&nbsp;|&nbsp; Pendaftaran kolaborasi riset semester genap telah dibuka. &nbsp;|&nbsp;
Pantau publikasi dosen
dan mahasiswa secara real-time. &nbsp;|&nbsp; Beasiswa KIP Kuliah 2026 dibuka — Daftar
sekarang! &nbsp;|&nbsp;
Beasiswa LPDP S2 tersedia hingga September 2026. &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
</div>
</div>
</div>
<!-- ================================================
HERO SECTION (#hero)
================================================ -->
<section id="hero" class="hero-section" aria-label="Header halaman beasiswa">
<div class="hero-bg"></div>
<div class="hero-content max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 w-full">
<!-- Breadcrumb -->
<nav class="breadcrumb" aria-label="Breadcrumb">
<a href="#" data-i18n="breadcrumb_home">Beranda</a>
<span class="sep"><i class="fas fa-chevron-right" style="font-size:7px;"></i></span>
<a href="#" data-i18n="ape_breadcrumb_profile">Pendidikan</a>
<span class="sep"><i class="fas fa-chevron-right" style="font-size:7px;"></i></span>
<span class="current" data-i18n="ape_breadcrumb_scholarship">Beasiswa</span>
</nav>
<div class="grid lg:grid-cols-2 gap-10 lg:gap-20 items-center">
<!-- Left: Text Content -->
<div data-aos="fade-right" data-aos-duration="700">
<!-- Badge (outline style seperti Figma) -->
<div class="hero-badge">
<span data-i18n="ape_hero_badge">PROGRAM BEASISWA</span>
</div>
<!-- H1 -->
<h1 class="hero-title" data-i18n-html="ape_hero_title">
Beasiswa Studi &amp;<br>Penelitian
</h1>
<!-- Subtitle -->
<p class="hero-subtitle" data-i18n="ape_hero_subtitle">
Teknik Informatika Universitas Tanjungpura
</p>
<!-- Description (sesuai teks Figma) -->
<p class="hero-desc" data-i18n="ape_hero_desc">
Temukan berbagai peluang beasiswa untuk mendukung perjalanan akademik dan penelitian Anda.
</p>
<!-- CTA Button (kuning, teks gelap — sesuai Figma) -->
<a href="#directory" class="hero-cta" aria-label="Lihat daftar beasiswa">
<span data-i18n="ape_hero_cta">Lihat Daftar Beasiswa</span>
<i class="fas fa-arrow-right"></i>
</a>
</div>
<!-- Right: Hero Image -->
<div class="hidden lg:block" data-aos="fade-left" data-aos-delay="150"
data-aos-duration="700">
<div class="hero-image-wrap">
<img src="assets/images/hero/mahasiswa.png"
alt="Mahasiswa Informatika UNTAN berprestasi" width="480" height="400"
onerror="this.src='https://images.unsplash.com/photo-1523050854058-8df90110c9f1?w=500&q=80'">
</div>
</div>
</div>
</div>
</section>
<!-- ================================================
STATISTIK COUNTER (#statistics)
================================================ -->
<section id="statistics" class="stats-section" aria-label="Statistik penerima beasiswa">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-3 divide-x divide-white/20">
<div class="stat-item" data-aos="fade-up" data-aos-delay="0">
<div class="stat-number" data-counter="20" id="ctr-total">0</div>
<div class="stat-label" data-i18n-html="ape_stats_total">
Total Mahasiswa Penerima<br>Beasiswa Semester Ini
</div>
</div>
<div class="stat-item" data-aos="fade-up" data-aos-delay="100">
<div class="stat-number" data-counter="12" id="ctr-s1">0</div>
<div class="stat-label" data-i18n-html="ape_stats_s1">
Total Penerima Beasiswa<br>Khusus S1
</div>
</div>
<div class="stat-item" data-aos="fade-up" data-aos-delay="200">
<div class="stat-number" data-counter="8" id="ctr-studi">0</div>
<div class="stat-label" data-i18n-html="ape_stats_studi">
Total Penerima Beasiswa<br>Studi Lanjut
</div>
</div>
</div>
</div>
</section>
<!-- ================================================
DIREKTORI BEASISWA (#directory)
================================================ -->
<section id="directory" class="py-16 bg-white" aria-label="Direktori beasiswa">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Section Header -->
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6 mb-10">
<div data-aos="fade-right">
<!-- Label Badge "DAFTAR LENGKAP" -->
<div class="section-badge mb-3">
<span data-i18n="ape_dir_label">DAFTAR LENGKAP</span>
</div>
<h2 class="section-title" data-i18n="ape_dir_title">Daftar Beasiswa</h2>
<p class="section-desc" data-i18n="ape_dir_desc">
Temukan beragam beasiswa dari berbagai sumber resmi yang tersedia untuk mahasiswa aktif.
</p>
</div>
</div>
<!-- Filter & Search Row (UC-02, UC-03) -->
<div class="flex flex-col lg:flex-row gap-4 mb-8" data-aos="fade-up" data-aos-delay="50">
<!-- Filter Tabs -->
<div class="filter-tabs flex-1" id="filter-tabs" role="tablist"
aria-label="Filter beasiswa">
<button class="filter-tab active" data-filter="semua" role="tab" aria-selected="true"
data-i18n="filter_all">Semua</button>
<button class="filter-tab" data-filter="s1" role="tab" aria-selected="false"
data-i18n="ape_filter_s1">S1</button>
<button class="filter-tab" data-filter="studi_lanjut" role="tab" aria-selected="false"
data-i18n="ape_filter_studi">Studi Lanjut</button>
</div>
<!-- Real-Time Search -->
<div class="flex flex-col sm:flex-row gap-3 flex-shrink-0">
<div class="search-wrapper w-full sm:w-64">
<i class="fas fa-search search-icon" aria-hidden="true"></i>
<input type="search" id="search-input" class="search-input"
placeholder="Cari beasiswa..." data-i18n-placeholder="ape_search_placeholder"
aria-label="Cari beasiswa" autocomplete="off" />
</div>
</div>
</div>
<!-- Cards Grid (rendered by filter.js) -->
<div id="beasiswa-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" role="list"
aria-label="Daftar kartu beasiswa">
<!-- Skeleton loading state -->
<div class="col-span-full flex flex-col items-center justify-center py-20 text-gray-300">
<i class="fas fa-spinner fa-spin text-4xl mb-4"></i>
<p class="text-sm font-medium">Memuat data beasiswa...</p>
</div>
</div>
<!-- Pagination (rendered by filter.js) -->
<div id="pagination" class="pagination-container" role="navigation"
aria-label="Navigasi halaman beasiswa"></div>
</div>
</section>
<!-- ================================================
POPUP MODAL DETAIL BEASISWA (UC-04, UC-05)
================================================ -->
<div id="popup-overlay" role="dialog" aria-modal="true" aria-labelledby="popup-title">
<!-- Backdrop (klik untuk tutup - UC-04) -->
<div id="popup-backdrop" class="popup-backdrop"></div>
<!-- Modal Card -->
<div id="popup-modal" class="popup-modal" tabindex="-1">
<!-- Close Button (×) -->
<button id="popup-close" class="popup-close" aria-label="Tutup detail beasiswa">
<i class="fas fa-times"></i>
</button>
<!-- Content (rendered by popup.js) -->
<div id="popup-content"></div>
</div>
</div>
<!-- ================================================
TESTIMONI (#testimonial) — UC-07
================================================ -->
<section id="testimonial" class="testimonials-section" aria-label="Testimoni penerima beasiswa">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Header -->
<div class="text-center mb-12" data-aos="fade-up">
<h2 class="section-title text-center" data-i18n="ape_testimonial_title">Apa Kata Mereka?</h2>
<p class="section-desc mx-auto text-center" data-i18n="ape_testimonial_desc">
Cerita nyata dari mahasiswa yang telah meraih beasiswa
</p>
</div>
<!-- Swiper Carousel -->
<div class="swiper testimonial-swiper" data-aos="fade-up" data-aos-delay="100">
<div class="swiper-wrapper pb-14" id="testimonial-wrapper">
<!-- Rendered by carousel.js -->
</div>
<div class="swiper-pagination testimonial-pagination"></div>
</div>
</div>
</section>
<!-- ================================================
CTA SECTION (#cta) — UC-06
================================================ -->
<section id="cta" class="cta-section" aria-label="Hubungi admin beasiswa">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="cta-card" data-aos="zoom-in" data-aos-duration="600">
<h2 class="cta-title" data-i18n="ape_cta_title">
Ingin Mengetahui Info Lebih Lanjut?
</h2>
<p class="cta-desc" data-i18n="ape_cta_desc">
Bersama kami membangun sumber daya manusia unggul melalui program beasiswa dan
kolaborasi riset
</p>
<div class="cta-buttons">
<!-- Scroll ke direktori -->
<a href="#directory" class="cta-btn-primary" aria-label="Lihat daftar beasiswa">
<span data-i18n="ape_cta_btn1">Daftar Beasiswa</span>
</a>
<!-- Kontak CTA -->
<a id="btn-open-contact" class="cta-btn-secondary" aria-label="Hubungi admin" href="https://wa.me/+6285171655161">
<span data-i18n="ape_cta_btn2">Hubungi Kami</span>
</a>
</div>
</div>
</div>
</section>
<div class="container mx-auto px-4 lg:px-8 py-12">
<div id="assignment-body" class="prose prose-slate max-w-none">
<!-- ================= START: BAGIAN YANG DIEDIT KELOMPOK ================= -->
<p>Tulis konten halaman kelompok di sini.</p>
<h3>Subjudul</h3>
<p>Gunakan paragraf, list, tabel, gambar, dan elemen konten lainnya.</p>
<!-- ================= END: BAGIAN YANG DIEDIT KELOMPOK ================= -->
</div>
</div>
@@ -1215,11 +1472,23 @@
</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;">
<img src="../../assets/global/footer.png" alt="Footer Illustration"
class="w-full h-auto object-cover object-bottom" style="object-position: bottom;">
</div>
</footer>
<script src="https://unpkg.com/aos@2.3.4/dist/aos.js"></script>
<script src="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.js"></script>
<script src="../../assets/global/localization.js"></script>
<script src="assets/js/counter.js"></script>
<script type="module" src="assets/js/API.js"></script>
<script src="assets/js/popup.js"></script>
<script src="assets/js/filter.js"></script>
<script src="assets/js/carousel.js"></script>
<script src="assets/js/main.js"></script>
<script>
(function () {
const menuItems = document.querySelectorAll('[data-menu]');
@@ -1312,27 +1581,27 @@
})();
</script>
<script>
(function() {
const mobileBtn = document.getElementById('mobile-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
const closeMobileBtn= document.getElementById('close-mobile-menu');
if (mobileBtn && mobileMenu) {
mobileBtn.addEventListener('click', function() {
const isOpen = !mobileMenu.classList.contains('-translate-x-full');
mobileMenu.classList.toggle('-translate-x-full');
mobileBtn.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
});
}
if (closeMobileBtn && mobileMenu) {
closeMobileBtn.addEventListener('click', function() {
mobileMenu.classList.add('-translate-x-full');
if (mobileBtn) mobileBtn.setAttribute('aria-expanded', 'false');
});
}
})();
</script>
<script>
(function () {
const mobileBtn = document.getElementById('mobile-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
const closeMobileBtn = document.getElementById('close-mobile-menu');
if (mobileBtn && mobileMenu) {
mobileBtn.addEventListener('click', function () {
const isOpen = !mobileMenu.classList.contains('-translate-x-full');
mobileMenu.classList.toggle('-translate-x-full');
mobileBtn.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
});
}
if (closeMobileBtn && mobileMenu) {
closeMobileBtn.addEventListener('click', function () {
mobileMenu.classList.add('-translate-x-full');
if (mobileBtn) mobileBtn.setAttribute('aria-expanded', 'false');
});
}
})();
</script>
<script src="../../assets/global/localization.js"></script>
</body>
</html>
+203
View File
@@ -0,0 +1,203 @@
{
"source": "https://www.schoters.com/id/beasiswa",
"title": "Kalender Beasiswa - Schoters",
"description": "Kalender dan informasi beasiswa terlengkap dari semua jenjang S1, S2, & S3 di setiap negara dan kampus impian beserta persyaratan dan proses pendaftarannya",
"filters": {
"jenjang": [
{ "level": "D2", "count": 2 },
{ "level": "D3", "count": 6 },
{ "level": "Non-Degree", "count": 20 },
{ "level": "S1", "count": 648 },
{ "level": "S2", "count": 683 },
{ "level": "S3", "count": 362 }
],
"tipe_pendanaan": [
{ "type": "Fully Funded", "count": 809 },
{ "type": "Partially Funded", "count": 590 }
],
"tipe_beasiswa": [
{ "type": "Company/Institution Scholarship", "count": 237 },
{ "type": "Government Scholarship", "count": 459 },
{ "type": "University Scholarship", "count": 830 }
],
"negara": [
{ "country": "Australia", "count": 161 },
{ "country": "Austria", "count": 19 },
{ "country": "Azerbaijan", "count": 6 },
{ "country": "Belgium", "count": 38 },
{ "country": "Brazil", "count": 2 },
{ "country": "Brunei", "count": 5 },
{ "country": "Canada", "count": 46 },
{ "country": "China", "count": 114 },
{ "country": "Czech Republic", "count": 2 },
{ "country": "Denmark", "count": 13 },
{ "country": "Egypt", "count": 3 },
{ "country": "Estonia", "count": 2 },
{ "country": "Finland", "count": 11 },
{ "country": "France", "count": 20 },
{ "country": "Germany", "count": 46 },
{ "country": "Hong Kong", "count": 24 },
{ "country": "Hungary", "count": 5 },
{ "country": "India", "count": 2 },
{ "country": "Indonesia", "count": 35 },
{ "country": "Ireland", "count": 8 },
{ "country": "Italy", "count": 19 },
{ "country": "Japan", "count": 254 },
{ "country": "Kazakhstan", "count": 3 },
{ "country": "Latvia", "count": 3 },
{ "country": "Libya", "count": 2 },
{ "country": "Luxembourg", "count": 1 },
{ "country": "Macau", "count": 6 },
{ "country": "Malaysia", "count": 12 },
{ "country": "Morocco", "count": 2 },
{ "country": "Netherlands", "count": 21 },
{ "country": "New Zealand", "count": 22 },
{ "country": "Pakistan", "count": 1 },
{ "country": "Philippines", "count": 2 },
{ "country": "Poland", "count": 9 },
{ "country": "Portugal", "count": 4 },
{ "country": "Qatar", "count": 6 },
{ "country": "Romania", "count": 9 },
{ "country": "Russia", "count": 6 },
{ "country": "Saudi Arabia", "count": 12 },
{ "country": "Scotland", "count": 2 },
{ "country": "Serbia", "count": 3 },
{ "country": "Singapore", "count": 49 },
{ "country": "Slovakia", "count": 1 },
{ "country": "South Korea", "count": 109 },
{ "country": "Spain", "count": 12 },
{ "country": "Sweden", "count": 11 },
{ "country": "Switzerland", "count": 19 },
{ "country": "Taiwan", "count": 112 },
{ "country": "Thailand", "count": 38 },
{ "country": "Turkey", "count": 11 },
{ "country": "United Arab Emirates", "count": 67 },
{ "country": "United Kingdom", "count": 76 },
{ "country": "United States", "count": 55 },
{ "country": "Universal", "count": 2 },
{ "country": "Vietnam", "count": 2 }
]
},
"scholarships_page_1": [
{
"name": "Boustany Foundation Scholarship, MBA - Harvard University",
"funding_type": "Partially Funded",
"level": ["S2"],
"country": "United States",
"open_date": "2026-05-21",
"deadline": "2027-05-31",
"status": "Pendaftaran dibuka",
"url": "https://www.schoters.com/id/beasiswa/boustany-foundation-scholarship-mba-harvard-university-1"
},
{
"name": "La Trobe University - La Trobe High Achiever Scholarship",
"funding_type": "Partially Funded",
"level": ["S1"],
"country": "Australia",
"open_date": "2023-09-29",
"deadline": "2026-12-31",
"status": "Pendaftaran dibuka",
"url": "https://www.schoters.com/id/beasiswa/la-trobe-university-la-trobe-high-achiever-scholarship-2"
},
{
"name": "Charles Sturt University - International Student Support Scholarship",
"funding_type": "Partially Funded",
"level": ["S1"],
"country": "Australia",
"open_date": "2026-05-21",
"deadline": "2026-12-31",
"status": "Pendaftaran dibuka",
"url": "https://www.schoters.com/id/beasiswa/charles-sturt-university-international-student-support-scholarship-3"
},
{
"name": "La Trobe University - La Trobe High Achiever Scholarship",
"funding_type": "Partially Funded",
"level": ["S2"],
"country": "Australia",
"open_date": "2023-09-29",
"deadline": "2026-12-31",
"status": "Pendaftaran dibuka",
"url": "https://www.schoters.com/id/beasiswa/la-trobe-university-la-trobe-high-achiever-scholarship-4"
},
{
"name": "ETH Zurich - Excellence Scholarship & Opportunity Programme (ESOP)",
"funding_type": "Fully Funded",
"level": ["S2"],
"country": "Switzerland",
"open_date": "2026-11-01",
"deadline": "2026-11-30",
"status": "Pendaftaran dibuka",
"url": "https://www.schoters.com/id/beasiswa/eth-zurich-excellence-scholarship-opportunity-programme-esop-5"
},
{
"name": "Southern Taiwan University of Science and Technology - The STUST Scholarship for International Students",
"funding_type": "Fully Funded",
"level": ["S3"],
"country": "Taiwan",
"open_date": "2026-09-15",
"deadline": "2026-11-30",
"status": "Pendaftaran dibuka",
"url": "https://www.schoters.com/id/beasiswa/southern-taiwan-university-of-science-and-technology-the-stust-scholarship-for-international-students-6"
},
{
"name": "Southern Taiwan University of Science and Technology - The STUST Scholarship for International Students",
"funding_type": "Partially Funded",
"level": ["S2"],
"country": "Taiwan",
"open_date": "2026-09-15",
"deadline": "2026-11-30",
"status": "Pendaftaran dibuka",
"url": "https://www.schoters.com/id/beasiswa/southern-taiwan-university-of-science-and-technology-the-stust-scholarship-for-international-students-7"
},
{
"name": "University of Melbourne - Melbourne International Undergraduate Scholarship",
"funding_type": "Partially Funded",
"level": ["S1"],
"country": "Australia",
"open_date": "2026-05-21",
"deadline": "2026-10-31",
"status": "Pendaftaran dibuka",
"url": "https://www.schoters.com/id/beasiswa/university-of-melbourne-melbourne-international-undergraduate-scholarship-8"
},
{
"name": "Charles Darwin University - CDU Global Merit Scholarship",
"funding_type": "Partially Funded",
"level": ["S1"],
"country": "Australia",
"open_date": "2026-05-21",
"deadline": "2026-10-09",
"status": "Pendaftaran dibuka",
"url": "https://www.schoters.com/id/beasiswa/charles-darwin-university-cdu-global-merit-scholarship-9"
},
{
"name": "Deakin University - Vice-Chancellor's International Scholarship",
"funding_type": "Partially Funded",
"level": ["S1", "S2"],
"country": "Australia",
"open_date": "2026-05-21",
"deadline": "2026-09-25",
"status": "Pendaftaran dibuka",
"url": "https://www.schoters.com/id/beasiswa/deakin-university-vice-chancellor-s-international-scholarship-10"
},
{
}
],
"pagination": {
"current_page": 1,
"total_pages": 153
},
"contact": {
"email": "ask@schoters.com",
"phone": "0811 9424 966",
"address": "Gedung Sinarmas MSIG Tower Lt. 33, Jl. Jenderal Sudirman Kav. 21, Karet Kuningan, Setiabudi, Jakarta Selatan, Jakarta 12920"
},
"social_media": {
"instagram": "https://www.instagram.com/schoters/",
"facebook": "https://www.facebook.com/schoters",
"youtube": "https://www.youtube.com/@Schoters",
"tiktok": "https://www.tiktok.com/@schoters.id",
"twitter_x": "https://x.com/schoters_com",
"linkedin": "https://id.linkedin.com/company/schoters"
}
}
+162
View File
@@ -0,0 +1,162 @@
# 🎓 Portal Informasi Penerimaan Mahasiswa Baru (PMB)
**Program Studi Informatika - Universitas Tanjungpura (UNTAN)**
![Screenshot Website](Screenshot.png)
*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

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1729 -834
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"name": "roda",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "roda",
"version": "1.0.0",
"license": "ISC"
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "roda",
"version": "1.0.0",
"description": "Project website untuk menampilkan sorotan dan prestasi karya mahasiswa program studi Informatika Universitas Tanjungpura.",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js",
"dev": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC"
}
+279
View File
@@ -0,0 +1,279 @@
const fs = require('fs');
const http = require('http');
const path = require('path');
const { URL } = require('url');
const ROOT_DIR = __dirname;
const PARENT_DIR = path.join(__dirname, '..', '..');
const INDEX_PATH = path.join(ROOT_DIR, 'index.html');
const ENV_PATH = path.join(ROOT_DIR, '.env');
const HOST = '127.0.0.1';
const PORT = Number(process.env.PORT || 3000);
function logServerError(scope, error, extra = {}) {
console.error(`[${scope}]`, error && error.stack ? error.stack : error, extra);
}
function parseEnvFile(filePath) {
if (!fs.existsSync(filePath)) {
return {};
}
const env = {};
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const separatorIndex = line.indexOf('=');
if (separatorIndex === -1) {
continue;
}
const key = line.slice(0, separatorIndex).trim();
let value = line.slice(separatorIndex + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
env[key] = value;
}
return env;
}
function getAppConfig() {
const env = {
...parseEnvFile(ENV_PATH),
...process.env
};
const dashboardProxyTemplates = [
env.DASHBOARD_PROXY_1,
env.DASHBOARD_PROXY_2,
env.DASHBOARD_PROXY_3
].filter(Boolean);
return {
dashboardTargetUrl: env.DASHBOARD_TARGET_URL || '',
dashboardProxyTemplates
};
}
const DASHBOARD_CACHE_TTL_MS = 5 * 60 * 1000;
let dashboardCache = { html: null, timestamp: 0 };
async function fetchDashboardHtml() {
const targetUrl = getAppConfig().dashboardTargetUrl;
if (!targetUrl) {
throw new Error('DASHBOARD_TARGET_URL kosong');
}
const now = Date.now();
if (dashboardCache.html && (now - dashboardCache.timestamp) < DASHBOARD_CACHE_TTL_MS) {
return { html: dashboardCache.html, cached: 'HIT' };
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 12000);
try {
const response = await fetch(targetUrl, {
signal: controller.signal,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'
}
});
if (!response.ok) {
throw new Error(`HTTP_ERROR_${response.status}`);
}
const html = await response.text();
dashboardCache = { html, timestamp: Date.now() };
return { html, cached: 'MISS' };
} catch (error) {
if (dashboardCache.html) {
return { html: dashboardCache.html, cached: 'STALE' };
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
function validateAppConfig(config) {
const errors = [];
if (!config.dashboardTargetUrl) {
errors.push('DASHBOARD_TARGET_URL belum diisi.');
}
if (!Array.isArray(config.dashboardProxyTemplates) || !config.dashboardProxyTemplates.length) {
errors.push('Minimal satu DASHBOARD_PROXY_* harus diisi.');
}
return errors;
}
function injectConfig(html) {
const configScript = `<script>window.APP_CONFIG = ${JSON.stringify(getAppConfig())};</script>`;
const defaultConfigScript = '<script>\n window.APP_CONFIG = window.APP_CONFIG || {\n dashboardTargetUrl: \'\',\n dashboardProxyTemplates: []\n };\n </script>';
if (html.includes(defaultConfigScript)) {
return html.replace(defaultConfigScript, configScript);
}
return html.replace('</head>', ` ${configScript}\n</head>`);
}
function sendHtml(res) {
try {
const html = fs.readFileSync(INDEX_PATH, 'utf8');
const configErrors = validateAppConfig(getAppConfig());
if (configErrors.length) {
logServerError('config', new Error('Invalid app config'), { configErrors });
}
const withConfig = injectConfig(html);
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(withConfig);
} catch (error) {
logServerError('sendHtml', error);
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Internal Server Error');
}
}
function getMimeType(pathname) {
const ext = path.extname(pathname).toLowerCase();
const mimeTypes = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.eot': 'application/vnd.ms-fontobject'
};
return mimeTypes[ext] || 'application/octet-stream';
}
function sendStaticFile(res, filepath) {
try {
if (!fs.existsSync(filepath)) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not found');
return;
}
const content = fs.readFileSync(filepath);
const mimeType = getMimeType(filepath);
res.writeHead(200, { 'Content-Type': mimeType });
res.end(content);
} catch (error) {
logServerError('sendStaticFile', error, { filepath });
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Internal Server Error');
}
}
function sendNotFound(res) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not found');
}
const server = http.createServer((req, res) => {
try {
const requestUrl = new URL(req.url, `http://${req.headers.host}`);
const pathname = requestUrl.pathname;
if (pathname === '/' || pathname === '/index.html') {
sendHtml(res);
return;
}
if (pathname === '/api/dashboard') {
fetchDashboardHtml()
.then(({ html, cached }) => {
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'X-Cache': cached
});
res.end(html);
})
.catch((error) => {
logServerError('api-dashboard', error);
res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('DASHBOARD_FETCH_FAILED');
});
return;
}
// Handle /assets/ from parent directory
if (pathname.startsWith('/assets/')) {
const assetsFilepath = path.join(PARENT_DIR, pathname);
const normalizedAssetsPath = path.normalize(assetsFilepath);
if (!normalizedAssetsPath.startsWith(path.normalize(PARENT_DIR))) {
sendNotFound(res);
return;
}
sendStaticFile(res, normalizedAssetsPath);
return;
}
// Serve static files from ROOT_DIR
const filepath = path.join(ROOT_DIR, pathname);
const normalizedPath = path.normalize(filepath);
// Security check: ensure the requested file is within ROOT_DIR
if (!normalizedPath.startsWith(path.normalize(ROOT_DIR))) {
sendNotFound(res);
return;
}
sendStaticFile(res, normalizedPath);
} catch (error) {
logServerError('request-handler', error, { url: req.url });
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Internal Server Error');
}
});
server.on('clientError', (error, socket) => {
logServerError('clientError', error);
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
});
server.listen(PORT, HOST, () => {
console.log(`Server ready at http://${HOST}:${PORT}`);
});
server.on('error', (error) => {
logServerError('server', error, { host: HOST, port: PORT });
process.exitCode = 1;
});
process.on('uncaughtException', (error) => {
logServerError('uncaughtException', error);
process.exitCode = 1;
});
process.on('unhandledRejection', (reason) => {
logServerError('unhandledRejection', reason instanceof Error ? reason : new Error(String(reason)));
process.exitCode = 1;
});
+300 -38
View File
@@ -1,48 +1,310 @@
:root {
--brand-navy: #003150;
--brand-yellow: #feb401;
--text-main: #1e293b;
--text-soft: #64748b;
--bg-soft: #f8fafc;
--line: #e2e8f0;
.bg-pattern {
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { font-family: "Roboto", Arial, sans-serif; color: var(--text-main); background: #fff; line-height: 1.6; }
.container { width: min(1100px, 92%); margin-inline: auto; }
/* Navbar & Accessibility Fixes */
#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 { background: var(--brand-navy); color: #fff; border-bottom: 4px solid var(--brand-yellow); padding: 14px 0; }
.brand { font-size: 1.1rem; font-weight: 700; letter-spacing: .02em; }
#nav-wrapper>div:first-child {
transform: translateZ(0);
-webkit-transform: translateZ(0);
}
.hero { position: relative; color: #fff; min-height: 220px; display: flex; align-items: flex-end; overflow: hidden; }
.hero-bg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; filter: brightness(0.45); }
.hero-inner { position: relative; z-index: 1; padding: 36px 0; }
.badge { display: inline-block; background: var(--brand-yellow); color: #111827; font-size: .68rem; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; padding: .2rem .45rem; margin-bottom: 12px; }
.hero h1 { margin: 0; font-size: clamp(1.6rem, 3.2vw, 2.8rem); line-height: 1.2; }
@media (max-width: 1023px) {
#nav-wrapper>div:first-child {
min-height: 0 !important;
max-height: none;
}
}
.layout { display: grid; grid-template-columns: 1fr; gap: 2rem; padding: 2rem 0 4rem; }
@media (min-width: 1024px) { .layout { grid-template-columns: minmax(0, 3fr) minmax(260px, 1fr); } }
html {
scroll-behavior: smooth;
}
.meta { font-size: .78rem; text-transform: uppercase; color: #475569; display: flex; flex-wrap: wrap; gap: .75rem 1rem; margin-bottom: 1rem; }
.cover { background: var(--bg-soft); border: 1px solid var(--line); margin-bottom: 1.5rem; }
.cover img { width: 100%; height: auto; display: block; }
.ticker-wrap {
mask-image: linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%);
-webkit-mask-image: linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%);
}
.article-body > * + * { margin-top: 1.1em; }
.article-body h2 { font-size: 1.35rem; margin: 1.6em 0 .5em; }
.article-body h3 { font-size: 1.1rem; margin: 1.4em 0 .4em; }
.article-body p { margin: 0 0 1em; }
.article-body ul, .article-body ol { padding-left: 1.4rem; }
.article-body blockquote { border-left: 4px solid #005eb8; background: #eef6ff; padding: .8rem 1rem; color: #334155; }
.article-body a { color: #005eb8; }
.article-body img { max-width: 100%; border-radius: 8px; }
.article-body code { background: #f1f5f9; padding: .1em .35em; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.ticker-track {
display: inline-flex;
white-space: nowrap;
min-width: max-content;
animation: tickerMove 30s linear infinite;
will-change: transform;
}
.note { border: 1px solid var(--line); background: var(--bg-soft); padding: 1rem; }
.note h3 { margin: 0 0 .5rem; font-size: .76rem; text-transform: uppercase; letter-spacing: .12em; color: #475569; }
.ticker-wrap:hover .ticker-track {
animation-play-state: paused;
}
.footer { margin-top: 2rem; background: var(--brand-navy); color: #fff; border-top: 4px solid var(--brand-yellow); }
.footer-inner { display: flex; align-items: center; justify-content: center; gap: .75rem; padding: 1rem 0; }
.footer-logo { height: 28px; width: auto; }
.footer-ornament { width: 100%; display: block; opacity: .9; }
@keyframes tickerMove {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
/* Responsive animation timing */
@media (max-width: 768px) {
.ticker-track {
animation-duration: 25s;
}
}
@media (min-width: 1366px) {
.ticker-track {
animation-duration: 35s;
}
}
.line-clamp-1 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.line-clamp-2 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.line-clamp-3 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.reveal {
opacity: 1;
transform: none;
}
.reveal-ready .reveal {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.65s ease, transform 0.65s ease;
}
.reveal-ready .reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
.ticker-track {
animation: none;
}
.reveal {
opacity: 1;
transform: none;
transition: none;
}
}
/* Dosen grid: subgrid rows agar header & footer setiap card sejajar */
@media (min-width: 1024px) {
#dosen-grid {
grid-template-rows: auto auto;
}
#dosen-grid > a {
grid-row: span 2;
display: grid;
grid-template-rows: subgrid;
}
/* White section: flex column so expertise sits at top, rank/link pinned to bottom */
#dosen-grid > a > div:last-child {
display: flex !important;
flex-direction: column !important;
padding-top: 1rem !important;
}
#dosen-grid > a > div:last-child > div:last-child {
margin-top: auto;
}
}
/* Custom responsive breakpoints - overriding Tailwind CDN defaults */
/* Mobile: 390px and up */
@media (min-width: 390px) {
body {
font-size: 14px;
}
}
/* Tablet: 768px and up */
@media (min-width: 768px) {
#kbk-card-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* Laptop/Desktop: 1024px and up */
@media (min-width: 1024px) {
nav.bg-\[#feb401\] {
display: block !important;
}
#lang-toggle-btn {
display: flex !important;
}
.hidden.lg\:flex {
display: flex !important;
}
/* Hide hamburger button on desktop (aligns with ACAAB lg:1024px) */
#mobile-menu-button {
display: none !important;
}
#kbk-card-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
#dosen-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
#riset-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
/* Desktop: 1366px and up */
@media (min-width: 1366px) {
#kbk-card-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
#riset-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
#dosen-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
/* Extra Large Desktop: 1920px and up */
@media (min-width: 1920px) {
body {
font-size: 16px;
}
}
/* ===== MEGA MENU STYLES FROM LIMAEM ===== */
.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;
}
#mega-overlay {
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease;
pointer-events: none;
}
#mega-overlay.active {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.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);
}
#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;
}
.nav-menu-item {
position: relative;
}
.nav-menu-item::after {
content: '';
position: absolute;
bottom: -12px;
left: 0;
right: 0;
height: 12px;
}
+123
View File
@@ -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;
}
}
+1
View File
@@ -0,0 +1 @@
@import url("repository/index.css");
+276
View File
@@ -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;
}
}
+286
View File
@@ -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;
}
+170
View File
@@ -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;
}
+117
View File
@@ -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;
}
+150
View File
@@ -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;
}
+7
View File
@@ -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");
+81
View File
@@ -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;
}
+172
View File
@@ -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;
}
}
+233
View File
@@ -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;
}
}
+45
View File
@@ -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;
}
}
+429
View File
@@ -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;
}
}
+15
View File
@@ -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);
}
+182
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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();
}
}
+208
View File
@@ -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());
})();
+1
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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);
}
}
+1
View File
@@ -0,0 +1 @@
import './viewer/viewer-app.js';
+122
View File
@@ -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);
}
}
+31
View File
@@ -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();
}
}
+8
View File
@@ -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);
});
+49
View File
@@ -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>
+7 -8
View File
@@ -886,9 +886,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
@@ -917,7 +917,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -926,9 +925,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true,
"funding": [
{
@@ -946,7 +945,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
+2
View File
@@ -2,10 +2,12 @@
"name": "web-student-safe",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Student-safe static page assignment kit for Informatika UNTAN",
"scripts": {
"dev": "vite",
"build": "vite build",
"test:tapops": "node --test groups/Tapops/tests/*.test.mjs",
"preview": "vite preview"
},
"devDependencies": {
+654
View File
@@ -0,0 +1,654 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
vite:
specifier: ^7.1.0
version: 7.3.3
packages:
'@esbuild/aix-ppc64@0.27.7':
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.7':
resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.7':
resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.7':
resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.7':
resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.7':
resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.7':
resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.7':
resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.7':
resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.7':
resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.7':
resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.7':
resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.7':
resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.7':
resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.7':
resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.7':
resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.7':
resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.7':
resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.7':
resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.7':
resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.7':
resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.7':
resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.7':
resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.7':
resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.7':
resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.7':
resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@rollup/rollup-android-arm-eabi@4.60.4':
resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.60.4':
resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.60.4':
resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.60.4':
resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.60.4':
resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.60.4':
resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.60.4':
resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.60.4':
resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.60.4':
resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.60.4':
resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.60.4':
resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.60.4':
resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.60.4':
resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.60.4':
resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.60.4':
resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.60.4':
resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.60.4':
resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.60.4':
resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.60.4':
resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.60.4':
resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==}
cpu: [x64]
os: [openbsd]
'@rollup/rollup-openharmony-arm64@4.60.4':
resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==}
cpu: [arm64]
os: [openharmony]
'@rollup/rollup-win32-arm64-msvc@4.60.4':
resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.60.4':
resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-gnu@4.60.4':
resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==}
cpu: [x64]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.60.4':
resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==}
cpu: [x64]
os: [win32]
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
esbuild@0.27.7:
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
engines: {node: '>=18'}
hasBin: true
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
nanoid@3.3.12:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@4.0.4:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
postcss@8.5.15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
rollup@4.60.4:
resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
vite@7.3.3:
resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
jiti: '>=1.21.0'
less: ^4.0.0
lightningcss: ^1.21.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
jiti:
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
snapshots:
'@esbuild/aix-ppc64@0.27.7':
optional: true
'@esbuild/android-arm64@0.27.7':
optional: true
'@esbuild/android-arm@0.27.7':
optional: true
'@esbuild/android-x64@0.27.7':
optional: true
'@esbuild/darwin-arm64@0.27.7':
optional: true
'@esbuild/darwin-x64@0.27.7':
optional: true
'@esbuild/freebsd-arm64@0.27.7':
optional: true
'@esbuild/freebsd-x64@0.27.7':
optional: true
'@esbuild/linux-arm64@0.27.7':
optional: true
'@esbuild/linux-arm@0.27.7':
optional: true
'@esbuild/linux-ia32@0.27.7':
optional: true
'@esbuild/linux-loong64@0.27.7':
optional: true
'@esbuild/linux-mips64el@0.27.7':
optional: true
'@esbuild/linux-ppc64@0.27.7':
optional: true
'@esbuild/linux-riscv64@0.27.7':
optional: true
'@esbuild/linux-s390x@0.27.7':
optional: true
'@esbuild/linux-x64@0.27.7':
optional: true
'@esbuild/netbsd-arm64@0.27.7':
optional: true
'@esbuild/netbsd-x64@0.27.7':
optional: true
'@esbuild/openbsd-arm64@0.27.7':
optional: true
'@esbuild/openbsd-x64@0.27.7':
optional: true
'@esbuild/openharmony-arm64@0.27.7':
optional: true
'@esbuild/sunos-x64@0.27.7':
optional: true
'@esbuild/win32-arm64@0.27.7':
optional: true
'@esbuild/win32-ia32@0.27.7':
optional: true
'@esbuild/win32-x64@0.27.7':
optional: true
'@rollup/rollup-android-arm-eabi@4.60.4':
optional: true
'@rollup/rollup-android-arm64@4.60.4':
optional: true
'@rollup/rollup-darwin-arm64@4.60.4':
optional: true
'@rollup/rollup-darwin-x64@4.60.4':
optional: true
'@rollup/rollup-freebsd-arm64@4.60.4':
optional: true
'@rollup/rollup-freebsd-x64@4.60.4':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.60.4':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.60.4':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.60.4':
optional: true
'@rollup/rollup-linux-arm64-musl@4.60.4':
optional: true
'@rollup/rollup-linux-loong64-gnu@4.60.4':
optional: true
'@rollup/rollup-linux-loong64-musl@4.60.4':
optional: true
'@rollup/rollup-linux-ppc64-gnu@4.60.4':
optional: true
'@rollup/rollup-linux-ppc64-musl@4.60.4':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.60.4':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.60.4':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.60.4':
optional: true
'@rollup/rollup-linux-x64-gnu@4.60.4':
optional: true
'@rollup/rollup-linux-x64-musl@4.60.4':
optional: true
'@rollup/rollup-openbsd-x64@4.60.4':
optional: true
'@rollup/rollup-openharmony-arm64@4.60.4':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.60.4':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.60.4':
optional: true
'@rollup/rollup-win32-x64-gnu@4.60.4':
optional: true
'@rollup/rollup-win32-x64-msvc@4.60.4':
optional: true
'@types/estree@1.0.8': {}
esbuild@0.27.7:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.7
'@esbuild/android-arm': 0.27.7
'@esbuild/android-arm64': 0.27.7
'@esbuild/android-x64': 0.27.7
'@esbuild/darwin-arm64': 0.27.7
'@esbuild/darwin-x64': 0.27.7
'@esbuild/freebsd-arm64': 0.27.7
'@esbuild/freebsd-x64': 0.27.7
'@esbuild/linux-arm': 0.27.7
'@esbuild/linux-arm64': 0.27.7
'@esbuild/linux-ia32': 0.27.7
'@esbuild/linux-loong64': 0.27.7
'@esbuild/linux-mips64el': 0.27.7
'@esbuild/linux-ppc64': 0.27.7
'@esbuild/linux-riscv64': 0.27.7
'@esbuild/linux-s390x': 0.27.7
'@esbuild/linux-x64': 0.27.7
'@esbuild/netbsd-arm64': 0.27.7
'@esbuild/netbsd-x64': 0.27.7
'@esbuild/openbsd-arm64': 0.27.7
'@esbuild/openbsd-x64': 0.27.7
'@esbuild/openharmony-arm64': 0.27.7
'@esbuild/sunos-x64': 0.27.7
'@esbuild/win32-arm64': 0.27.7
'@esbuild/win32-ia32': 0.27.7
'@esbuild/win32-x64': 0.27.7
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
fsevents@2.3.3:
optional: true
nanoid@3.3.12: {}
picocolors@1.1.1: {}
picomatch@4.0.4: {}
postcss@8.5.15:
dependencies:
nanoid: 3.3.12
picocolors: 1.1.1
source-map-js: 1.2.1
rollup@4.60.4:
dependencies:
'@types/estree': 1.0.8
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.60.4
'@rollup/rollup-android-arm64': 4.60.4
'@rollup/rollup-darwin-arm64': 4.60.4
'@rollup/rollup-darwin-x64': 4.60.4
'@rollup/rollup-freebsd-arm64': 4.60.4
'@rollup/rollup-freebsd-x64': 4.60.4
'@rollup/rollup-linux-arm-gnueabihf': 4.60.4
'@rollup/rollup-linux-arm-musleabihf': 4.60.4
'@rollup/rollup-linux-arm64-gnu': 4.60.4
'@rollup/rollup-linux-arm64-musl': 4.60.4
'@rollup/rollup-linux-loong64-gnu': 4.60.4
'@rollup/rollup-linux-loong64-musl': 4.60.4
'@rollup/rollup-linux-ppc64-gnu': 4.60.4
'@rollup/rollup-linux-ppc64-musl': 4.60.4
'@rollup/rollup-linux-riscv64-gnu': 4.60.4
'@rollup/rollup-linux-riscv64-musl': 4.60.4
'@rollup/rollup-linux-s390x-gnu': 4.60.4
'@rollup/rollup-linux-x64-gnu': 4.60.4
'@rollup/rollup-linux-x64-musl': 4.60.4
'@rollup/rollup-openbsd-x64': 4.60.4
'@rollup/rollup-openharmony-arm64': 4.60.4
'@rollup/rollup-win32-arm64-msvc': 4.60.4
'@rollup/rollup-win32-ia32-msvc': 4.60.4
'@rollup/rollup-win32-x64-gnu': 4.60.4
'@rollup/rollup-win32-x64-msvc': 4.60.4
fsevents: 2.3.3
source-map-js@1.2.1: {}
tinyglobby@0.2.16:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
vite@7.3.3:
dependencies:
esbuild: 0.27.7
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.15
rollup: 4.60.4
tinyglobby: 0.2.16
optionalDependencies:
fsevents: 2.3.3
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: set this to true or false
+125
View File
@@ -0,0 +1,125 @@
import { cpSync, existsSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';
const root = path.dirname(fileURLToPath(import.meta.url));
const excludedDirs = new Set(['.git', 'dist', 'node_modules']);
const rodaDashboardUrl = 'https://dashboard.informatika.untan.ac.id/index.php?penelitian';
let rodaDashboardHtmlCache;
function collectHtmlInputs(dir) {
const inputs = {};
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (!excludedDirs.has(entry.name)) {
Object.assign(inputs, collectHtmlInputs(fullPath));
}
continue;
}
if (entry.isFile() && entry.name.endsWith('.html')) {
const relativePath = path.relative(root, fullPath);
const inputName = relativePath
.replace(/\.html$/, '')
.replace(/[^a-zA-Z0-9_-]+/g, '_');
inputs[inputName] = fullPath;
}
}
return inputs;
}
function copyStaticAssets() {
return {
name: 'copy-static-assets',
closeBundle() {
const source = path.join(root, 'assets');
const destination = path.join(root, 'dist', 'assets');
if (existsSync(source)) {
cpSync(source, destination, { recursive: true });
}
},
};
}
async function fetchRodaDashboardHtml() {
if (rodaDashboardHtmlCache !== undefined) {
return rodaDashboardHtmlCache;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 12000);
try {
const response = await fetch(rodaDashboardUrl, {
signal: controller.signal,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
rodaDashboardHtmlCache = await response.text();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[roda-dashboard] Build-time dashboard fetch skipped: ${message}`);
rodaDashboardHtmlCache = '';
} finally {
clearTimeout(timeoutId);
}
return rodaDashboardHtmlCache;
}
function escapeJsonScriptContent(value) {
return JSON.stringify(value)
.replaceAll('<', '\\u003c')
.replaceAll('>', '\\u003e')
.replaceAll('&', '\\u0026')
.replaceAll('\u2028', '\\u2028')
.replaceAll('\u2029', '\\u2029');
}
function embedRodaDashboardData() {
let command = 'serve';
return {
name: 'embed-roda-dashboard-data',
configResolved(config) {
command = config.command;
},
async transformIndexHtml(html, context) {
const filename = context?.filename ? path.resolve(context.filename) : '';
const rodaIndex = path.join(root, 'groups', 'RODA', 'index.html');
if (filename !== rodaIndex || command !== 'build') {
return html.replace('<!-- RODA_DASHBOARD_DATA -->', '');
}
const dashboardHtml = await fetchRodaDashboardHtml();
const payload = `<script type="application/json" id="roda-dashboard-html">${escapeJsonScriptContent(dashboardHtml)}</script>`;
return html.replace('<!-- RODA_DASHBOARD_DATA -->', payload);
},
};
}
export default defineConfig({
plugins: [embedRodaDashboardData(), copyStaticAssets()],
build: {
rollupOptions: {
input: collectHtmlInputs(root),
},
},
});