/** * 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, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // 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 ? `
${this.tags.map(t => `${DocumentFormatter.escapeHtml(t)}`).join('')}
` : ''; card.innerHTML = `

${safeTitle}

${typeUpper} ${formattedDate}
${tagBadgesHtml}
Buka Berkas Unduh Berkas
`; 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 { 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.dateStartParts = { dd: document.getElementById('date-start-dd'), mm: document.getElementById('date-start-mm'), yyyy: document.getElementById('date-start-yyyy'), picker: document.getElementById('date-start-picker'), calBtn: document.getElementById('date-start-cal-btn'), }; this.dateEndParts = { dd: document.getElementById('date-end-dd'), mm: document.getElementById('date-end-mm'), yyyy: document.getElementById('date-end-yyyy'), picker: document.getElementById('date-end-picker'), calBtn: document.getElementById('date-end-cal-btn'), }; this.cards = []; this.paginationContainer = null; this.state = { filter: 'all', // 'all' or a specific tag string search: '', startDate: '', endDate: '', sort: 'newest', page: 1, }; } // Returns YYYY-MM-DD string only when dd, mm, and yyyy are all filled and valid. _getDateString(parts) { const dd = parts.dd?.value.trim() || ''; const mm = parts.mm?.value.trim() || ''; const yyyy = parts.yyyy?.value.trim() || ''; if (!dd || !mm || yyyy.length < 4) return ''; const d = parseInt(dd, 10); const m = parseInt(mm, 10); const y = parseInt(yyyy, 10); if (isNaN(d) || isNaN(m) || isNaN(y)) return ''; if (d < 1 || d > 31 || m < 1 || m > 12 || y < 1 || y > 9999) return ''; return `${String(y).padStart(4, '0')}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`; } // Wire up a set of dd/mm/yyyy text inputs: // • numeric-only keydown guard // • auto-advance dd→mm after 2 chars, mm→yyyy after 2 chars // • backspace from empty field navigates back // • calendar button opens native date picker; selection syncs to text inputs // • calls onUpdate() on every change _bindDateParts(parts, onUpdate) { const { dd, mm, yyyy, picker, calBtn } = parts; const CTRL_KEYS = new Set(['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'Enter']); const setup = (input, prev, next) => { if (!input) return; input.addEventListener('keydown', (e) => { if (!/^\d$/.test(e.key) && !CTRL_KEYS.has(e.key)) e.preventDefault(); if (e.key === 'Backspace' && !input.value && prev) prev.focus(); }); input.addEventListener('input', () => { input.value = input.value.replace(/\D/g, ''); onUpdate(); const max = parseInt(input.getAttribute('maxlength'), 10); if (next && input.value.length >= max) next.focus(); }); }; setup(dd, null, mm); setup(mm, dd, yyyy); setup(yyyy, mm, null); // Calendar button → open native picker, then sync selection back to text inputs. if (calBtn && picker) { calBtn.addEventListener('click', () => { try { picker.showPicker(); } catch { picker.click(); } }); picker.addEventListener('change', () => { if (!picker.value) return; const [y, m, d] = picker.value.split('-'); if (dd) dd.value = d; if (mm) mm.value = m; if (yyyy) yyyy.value = y; onUpdate(); }); } } 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