/** * 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; } } /* =========================================== DateRangePicker (Custom Calendar/Preset Popover) =========================================== */ 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}`; } } /* =========================================== 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.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