/**
* 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}
`;
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