feat(tapops): add css modules, js scripts, and viewer page

This commit is contained in:
Dodo
2026-06-04 14:40:40 +07:00
parent 0ebc25809b
commit c9d96eabe4
16 changed files with 4379 additions and 302 deletions
+182
View File
@@ -0,0 +1,182 @@
/**
* 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...",
// 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...",
// 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());
})();
+775
View File
@@ -0,0 +1,775 @@
/**
* Main JavaScript — Informatika UNTAN 2026
* OOP modules:
* DirectusClient — API base + URL builders + fetch
* FileDownloader — blob-based force-download with fallback
* DocumentFormatter — pure formatting / mapping / escaping utilities
* DocumentCardView — renders a single card and binds its actions
* DocumentRepository — controller for the repository section
* CounterAnimation — scroll-triggered number animation
*/
/* ===========================================
DirectusClient
=========================================== */
class DirectusClient {
static API_BASE = 'https://api.ifuntanhub.dev';
static itemsUrl(collection) {
return `${this.API_BASE}/items/${collection}?limit=-1`;
}
static assetUrl(uuid) {
return `${this.API_BASE}/assets/${uuid}`;
}
static viewerUrl(item) {
const type = DocumentFormatter.fileTypeFromName(item.nama_berkas);
const params = new URLSearchParams({
id: 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');
}
}
/* ===========================================
FileDownloader
=========================================== */
class FileDownloader {
static async download(url, filename) {
try {
const response = await fetch(url);
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);
const fallbackUrl = url.includes('?') ? `${url}&download` : `${url}?download`;
this._click(fallbackUrl, filename);
}
}
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);
}
}
/* ===========================================
DocumentFormatter
=========================================== */
class DocumentFormatter {
static MONTHS_ID = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'];
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()}`;
}
/**
* Map a document's parsed tags to one of the fixed UI categories.
* Uses the tags array for precise matching instead of raw string search.
*/
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 categoryFromTags(tags) {
if (!tags || tags.length === 0) return 'umum';
const lower = tags.map(t => t.toLowerCase());
for (const rule of this.CATEGORY_RULES) {
if (rule.match.some(m => lower.includes(m))) return rule.key;
}
return 'umum';
}
static fileTypeFromName(name) {
const lower = (name || '').toLowerCase();
if (lower.endsWith('.docx') || lower.endsWith('.doc')) return 'docx';
return 'pdf';
}
static downloadFilename(name, type) {
const ext = `.${type}`;
return name.toLowerCase().endsWith(ext) ? name : `${name}${ext}`;
}
// Escape API-supplied strings before splicing into innerHTML templates.
static escapeHtml(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Escape values destined for HTML attribute slots (href, etc.).
static escapeAttr(str) {
return this.escapeHtml(str);
}
/**
* Parse the `tag` field from the API.
* Handles two formats:
* 1. Comma-separated string: "akademik,panduan,2024"
* 2. JSON array string: '["panduan","proposal"]'
* Returns an array of trimmed, non-empty tag strings.
*/
static parseTags(raw) {
if (!raw) return [];
const str = String(raw).trim();
if (!str) return [];
// Attempt JSON parse first (handles '["a","b"]' format).
if (str.startsWith('[')) {
try {
const arr = JSON.parse(str);
if (Array.isArray(arr)) {
return arr.map(t => String(t).trim()).filter(Boolean);
}
} catch { /* fall through to comma-split */ }
}
// Comma-separated fallback.
return str.split(',').map(t => t.trim()).filter(Boolean);
}
}
/* ===========================================
DocumentCardView
=========================================== */
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.fileType = DocumentFormatter.fileTypeFromName(this.title);
this.assetUrl = DirectusClient.assetUrl(item.file);
this.viewerUrl = DirectusClient.viewerUrl(item);
this.downloadName = DocumentFormatter.downloadFilename(this.title, this.fileType);
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.fileType === 'pdf' ? 'doc-type-pdf' : 'doc-type-docx';
const iconClass = this.fileType === 'pdf' ? 'fas fa-file-pdf' : 'fas fa-file-word';
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(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;
}
}
/* ===========================================
CustomDropdown
=========================================== */
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;
}
}
/* ===========================================
DocumentRepository
=========================================== */
class DocumentRepository {
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.dateStartInput = document.getElementById('date-start');
this.dateEndInput = document.getElementById('date-end');
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.state = {
filter: 'all', // 'all' or a specific tag string
search: '',
startDate: '',
endDate: '',
sort: 'newest',
};
}
async init() {
if (!this.gridContainer) return;
this._bindControls();
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();
}
}
// 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;
// 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() {
// Tag filter buttons are built dynamically after API fetch
// (see _buildTagFilters). Only bind search/date/sort here.
this.searchInput?.addEventListener('input', (e) => {
this.state.search = e.target.value.toLowerCase().trim();
this._applyFilters();
});
this.dateStartInput?.addEventListener('change', (e) => {
this.state.startDate = e.target.value;
this._applyFilters();
});
this.dateEndInput?.addEventListener('change', (e) => {
this.state.endDate = e.target.value;
this._applyFilters();
});
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._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() {
this.cards.forEach(({ view, element }) => {
if (this._cardMatches(view)) {
element.style.display = 'flex';
setTimeout(() => {
element.style.opacity = '1';
element.style.transform = 'translateY(0)';
}, 10);
} else {
element.style.display = 'none';
element.style.opacity = '0';
element.style.transform = 'translateY(10px)';
}
});
}
_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));
}
}
/* ===========================================
CounterAnimation
=========================================== */
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);
}
}
/* ===========================================
App entry
=========================================== */
document.addEventListener('DOMContentLoaded', () => {
new DocumentRepository().init();
});
+220
View File
@@ -0,0 +1,220 @@
/**
* Viewer page — renders a single Directus asset in-browser.
* PDF → native <iframe>
* DOCX → docx-preview library (renders document XML to HTML in-page)
*
* URL params: ?id={uuid}&name={filename}&type={pdf|docx}
*/
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}).`);
// If the response supports streaming and we have a progress callback,
// pipe through a progress-tracking reader.
const contentLength = response.headers.get('Content-Length');
if (onProgress && contentLength && response.body) {
const total = 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();
}
}
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.type === 'docx') {
await this._renderDocx();
} else {
this._renderPdf();
}
} 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}`;
// Use blob-based download so the browser always triggers a save
// dialog rather than navigating to the file (cross-origin `download`
// attribute is ignored by browsers).
this.downloadEl.removeAttribute('href');
this.downloadEl.style.cursor = 'pointer';
this.downloadEl.addEventListener('click', async (e) => {
e.preventDefault();
// Visual feedback while downloading.
const origHtml = this.downloadEl.innerHTML;
this.downloadEl.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Mengunduh…';
this.downloadEl.style.pointerEvents = 'none';
try {
const blob = await ViewerApi.fetchBlob(this.assetUrl);
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
} catch (err) {
console.warn('Blob download failed, falling back:', err);
// Fallback: open asset URL with ?download param
window.open(`${this.assetUrl}?download`, '_blank');
} finally {
this.downloadEl.innerHTML = origHtml;
this.downloadEl.style.pointerEvents = '';
}
});
}
_renderPdf() {
const iframe = document.createElement('iframe');
iframe.className = 'viewer-pdf-frame';
iframe.title = this.name;
iframe.src = this.assetUrl;
this.contentEl.replaceChildren(iframe);
}
/**
* Lazy-load a script by URL. Returns a promise that resolves on load.
*/
static _loadScript(src) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src;
s.onload = resolve;
s.onerror = () => reject(new Error(`Gagal memuat skrip: ${src}`));
document.head.appendChild(s);
});
}
async _renderDocx() {
// Show progress bar immediately.
const progressBar = this._showProgress();
// Load DOCX libraries and fetch the blob in parallel.
const [blob] = await Promise.all([
ViewerApi.fetchBlob(this.assetUrl, (ratio) => {
progressBar.update(ratio);
}),
this._loadDocxLibraries(),
]);
progressBar.finish();
if (typeof docx === 'undefined' || typeof docx.renderAsync !== 'function') {
throw new Error('Library docx-preview belum dimuat.');
}
const container = document.createElement('div');
container.className = 'viewer-docx-content';
this.contentEl.replaceChildren(container);
await docx.renderAsync(blob, container, null, {
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
});
}
async _loadDocxLibraries() {
if (typeof docx !== 'undefined') return; // already loaded
await FileViewer._loadScript('https://unpkg.com/jszip@3.10.1/dist/jszip.min.js');
await FileViewer._loadScript('https://unpkg.com/docx-preview@0.3.5/dist/docx-preview.js');
}
/**
* Show a thin progress bar at the top of the content area.
* Returns { update(ratio), finish() }.
*/
_showProgress() {
const bar = document.createElement('div');
bar.className = 'viewer-progress-bar';
const fill = document.createElement('div');
fill.className = 'viewer-progress-fill';
bar.appendChild(fill);
// Insert above the loading spinner.
this.contentEl.prepend(bar);
return {
update(ratio) {
fill.style.width = `${Math.min(Math.round(ratio * 100), 100)}%`;
},
finish() {
fill.style.width = '100%';
setTimeout(() => bar.remove(), 300);
},
};
}
_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);
}
}
document.addEventListener('DOMContentLoaded', () => {
new FileViewer().init();
});