Files
roda-student-web-if-develop…/groups/Tapops/js/main.js
T
Dodo 529693cd5f feat: implementasi custom date range picker dengan preset dan navigasi kalender
* Mengganti field input tanggal yang sudah ada dengan UI date range picker baru.
* Menambahkan popover kalender untuk memilih rentang tanggal dengan navigasi bulan.
* Menambahkan tombol preset untuk pilihan rentang tanggal umum secara cepat, seperti Hari Ini dan 7 Hari Terakhir.
* Memperbarui string lokalisasi untuk fitur date range picker baru.
* Melakukan refactor JavaScript untuk menangani pemilihan tanggal dan state management pada picker baru.
2026-06-04 17:28:18 +07:00

1600 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}
}
/* ===========================================
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 <select> dropdown (hidden on desktop via CSS)
* Both stay in sync through a shared _setFilter() helper.
*/
_buildCategoryFilters(items) {
if (!this.filterWrapper) return;
// Clear skeleton placeholder.
this.filterWrapper.innerHTML = '';
// ── Desktop buttons ──
const btnGroup = document.createElement('div');
btnGroup.className = 'filter-btn-group';
DocumentRepository.CATEGORIES.forEach(({ key, i18n, label }) => {
const isActive = key === 'all';
const btn = document.createElement('button');
btn.className = 'doc-filter-btn' + (isActive ? ' active' : '');
btn.dataset.filter = key;
btn.textContent = (window.tpT?.(i18n)) || label;
btn.setAttribute('data-tp-i18n', i18n);
btn.addEventListener('click', () => this._setFilter(key));
btnGroup.appendChild(btn);
});
this.filterWrapper.appendChild(btnGroup);
// ── Mobile Custom UI Dropdown ──
if (this.mobileFilterWrapper) {
const dropdownOptions = DocumentRepository.CATEGORIES.map(({ key, i18n, label }) => ({
value: key,
i18nKey: i18n,
text: (window.tpT?.(i18n)) || label
}));
this._mobileDropdown = new CustomDropdown({
container: this.mobileFilterWrapper,
options: dropdownOptions,
defaultValue: this.state.filter,
onChange: (value) => this._setFilter(value)
});
}
// Store refs for syncing.
this._filterBtnGroup = btnGroup;
// Re-translate if i18n is active.
window.applyTp?.(this.filterWrapper);
}
/**
* Shared filter setter — keeps buttons + dropdown in sync.
*/
_setFilter(key) {
this.state.filter = key;
this.state.page = 1;
// Sync buttons.
if (this._filterBtnGroup) {
this._filterBtnGroup.querySelectorAll('.doc-filter-btn')
.forEach(b => b.classList.toggle('active', b.dataset.filter === key));
}
// Sync custom dropdown.
if (this._mobileDropdown && this._mobileDropdown.getValue() !== key) {
this._mobileDropdown.setValue(key);
}
this._applyFilters();
}
_bindControls() {
this.searchInput?.addEventListener('input', (e) => {
this.state.search = e.target.value.toLowerCase().trim();
this.state.page = 1;
this._applyFilters();
});
// Initialize Custom Date Range Picker
const datePickerGroup = document.getElementById('date-range-control-group');
if (datePickerGroup) {
this.dateRangePicker = new DateRangePicker({
containerId: 'date-range-control-group',
onChange: (startDate, endDate) => {
this.state.startDate = startDate;
this.state.endDate = endDate;
this.state.page = 1;
this._applyFilters();
}
});
window.applyTp?.(datePickerGroup);
}
if (this.sortFilterWrapper) {
const sortOptions = [
{ value: 'newest', i18nKey: 'sort_newest', text: (window.tpT?.('sort_newest')) || 'Terbaru' },
{ value: 'oldest', i18nKey: 'sort_oldest', text: (window.tpT?.('sort_oldest')) || 'Terlama' },
{ value: 'az', i18nKey: 'sort_az', text: (window.tpT?.('sort_az')) || 'Nama A-Z' }
];
this._sortDropdown = new CustomDropdown({
container: this.sortFilterWrapper,
options: sortOptions,
defaultValue: this.state.sort,
prefixIcon: 'fas fa-sort-amount-down',
onChange: (value) => {
this.state.sort = value;
this.state.page = 1;
this._applySort();
this._applyFilters();
}
});
window.applyTp?.(this.sortFilterWrapper);
}
}
_renderCards(items) {
this.gridContainer.innerHTML = '';
if (items.length === 0) {
this._renderState('repo-empty', 'fas fa-folder-open', '#cbd5e1', '#64748b',
'state_empty', 'Belum ada dokumen yang tersedia di repositori.');
return;
}
this.cards = items.map(item => {
const view = new DocumentCardView(item);
const element = view.render();
this.gridContainer.appendChild(element);
return { view, element };
});
// Translate the freshly-rendered card labels (Buka/Unduh Berkas).
window.applyTp?.(this.gridContainer);
}
_renderError() {
this._renderState('repo-error', 'fas fa-exclamation-triangle', '#f87171', '#ef4444',
'state_error', 'Gagal memuat berkas. Silakan periksa koneksi internet Anda atau coba lagi nanti.');
}
// Build the empty/error placeholder via safe DOM APIs (no innerHTML).
// `i18nKey` lets the Tapops i18n layer re-translate the message on toggle.
_renderState(className, iconClass, iconColor, textColor, i18nKey, message) {
this.cards = [];
this.gridContainer.innerHTML = '';
const wrapper = document.createElement('div');
wrapper.className = className;
Object.assign(wrapper.style, {
gridColumn: '1 / -1',
textAlign: 'center',
padding: '60px 20px',
color: textColor,
fontWeight: '700',
fontSize: '1rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '15px',
});
const icon = document.createElement('i');
icon.className = iconClass;
Object.assign(icon.style, { fontSize: '3rem', color: iconColor });
const span = document.createElement('span');
if (i18nKey) span.setAttribute('data-tp-i18n', i18nKey);
span.textContent = (window.tpT?.(i18nKey)) || message;
wrapper.appendChild(icon);
wrapper.appendChild(span);
this.gridContainer.appendChild(wrapper);
}
_applyFilters() {
const matched = this.cards.filter(({ view }) => this._cardMatches(view));
const total = matched.length;
// Handle zero-results state when cards exist but none match the filter.
this._toggleNoResultsState(total === 0 && this.cards.length > 0);
if (total === 0) {
this.cards.forEach(({ element }) => { element.style.display = 'none'; });
this._renderPagination(0, 0);
return;
}
const totalPages = Math.ceil(total / DocumentRepository.PAGE_SIZE);
// Clamp page to valid range after filter changes.
this.state.page = Math.min(Math.max(this.state.page, 1), totalPages);
const start = (this.state.page - 1) * DocumentRepository.PAGE_SIZE;
const pageSet = new Set(
matched.slice(start, start + DocumentRepository.PAGE_SIZE).map(c => c.element)
);
this.cards.forEach(({ element }) => {
const show = pageSet.has(element);
element.style.display = show ? 'flex' : 'none';
if (show) {
setTimeout(() => {
element.style.opacity = '1';
element.style.transform = 'translateY(0)';
}, 10);
} else {
element.style.opacity = '0';
element.style.transform = 'translateY(10px)';
}
});
this._renderPagination(totalPages, total);
}
_toggleNoResultsState(show) {
const existing = this.gridContainer.querySelector('.repo-no-results');
if (show && !existing) {
const wrapper = document.createElement('div');
wrapper.className = 'repo-no-results';
Object.assign(wrapper.style, {
gridColumn: '1 / -1',
textAlign: 'center',
padding: '60px 20px',
color: '#64748b',
fontWeight: '700',
fontSize: '1rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '15px',
});
const icon = document.createElement('i');
icon.className = 'fas fa-search';
Object.assign(icon.style, { fontSize: '3rem', color: '#cbd5e1' });
const span = document.createElement('span');
span.setAttribute('data-tp-i18n', 'state_no_results');
span.textContent = (window.tpT?.('state_no_results')) || 'Tidak ada berkas yang cocok dengan filter saat ini.';
wrapper.appendChild(icon);
wrapper.appendChild(span);
this.gridContainer.appendChild(wrapper);
} else if (!show && existing) {
existing.remove();
}
}
_renderPagination(totalPages, total) {
if (!this.paginationContainer) return;
this.paginationContainer.innerHTML = '';
if (total === 0 || totalPages <= 1) return;
const current = this.state.page;
const start = (current - 1) * DocumentRepository.PAGE_SIZE + 1;
const end = Math.min(current * DocumentRepository.PAGE_SIZE, total);
const info = document.createElement('span');
info.className = 'pagination-info';
info.textContent = `${start}${end} dari ${total} berkas`;
this.paginationContainer.appendChild(info);
const nav = document.createElement('nav');
nav.className = 'pagination-nav';
nav.setAttribute('aria-label', 'Navigasi halaman dokumen');
const prev = document.createElement('button');
prev.className = 'pagination-btn' + (current <= 1 ? ' disabled' : '');
prev.disabled = current <= 1;
prev.setAttribute('aria-label', 'Halaman sebelumnya');
const prevIcon = document.createElement('i');
prevIcon.className = 'fas fa-chevron-left';
prev.appendChild(prevIcon);
prev.addEventListener('click', () => {
if (this.state.page > 1) { this.state.page--; this._applyFilters(); this._scrollToGrid(); }
});
nav.appendChild(prev);
this._paginationRange(current, totalPages).forEach(p => {
if (p === '...') {
const el = document.createElement('span');
el.className = 'pagination-ellipsis';
el.setAttribute('aria-hidden', 'true');
el.textContent = '…';
nav.appendChild(el);
} else {
const btn = document.createElement('button');
btn.className = 'pagination-btn pagination-number' + (p === current ? ' active' : '');
btn.textContent = p;
btn.setAttribute('aria-label', `Halaman ${p}`);
if (p === current) btn.setAttribute('aria-current', 'page');
btn.addEventListener('click', () => {
this.state.page = p; this._applyFilters(); this._scrollToGrid();
});
nav.appendChild(btn);
}
});
const next = document.createElement('button');
next.className = 'pagination-btn' + (current >= totalPages ? ' disabled' : '');
next.disabled = current >= totalPages;
next.setAttribute('aria-label', 'Halaman berikutnya');
const nextIcon = document.createElement('i');
nextIcon.className = 'fas fa-chevron-right';
next.appendChild(nextIcon);
next.addEventListener('click', () => {
if (this.state.page < totalPages) { this.state.page++; this._applyFilters(); this._scrollToGrid(); }
});
nav.appendChild(next);
this.paginationContainer.appendChild(nav);
}
// Returns page numbers and '...' ellipsis placeholders for the given range.
_paginationRange(current, total) {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const pages = [1];
const lo = Math.max(2, current - 1);
const hi = Math.min(total - 1, current + 1);
if (lo > 2) pages.push('...');
for (let i = lo; i <= hi; i++) pages.push(i);
if (hi < total - 1) pages.push('...');
pages.push(total);
return pages;
}
_scrollToGrid() {
document.getElementById('repository')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
_cardMatches(view) {
const { filter, search } = this.state;
const matchesCategory = filter === 'all' || view.category === filter;
const matchesTitle = view.title.toLowerCase().includes(search);
const matchesTags = search
? view.tags.some(t => t.toLowerCase().includes(search))
: false;
const matchesSearch = matchesTitle || matchesTags;
return matchesCategory && matchesSearch && this._dateMatches(view.dateKey);
}
_dateMatches(dateKey) {
const { startDate, endDate } = this.state;
if (!dateKey) return !startDate && !endDate;
const cardDate = new Date(dateKey);
if (startDate && cardDate < new Date(startDate)) return false;
if (endDate) {
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
if (cardDate > end) return false;
}
return true;
}
_applySort() {
const { sort } = this.state;
this.cards.sort((a, b) => {
if (sort === 'newest') return b.view.dateKey.localeCompare(a.view.dateKey);
if (sort === 'oldest') return a.view.dateKey.localeCompare(b.view.dateKey);
if (sort === 'az') return a.view.title.toLowerCase().localeCompare(b.view.title.toLowerCase());
return 0;
});
this.cards.forEach(({ element }) => this.gridContainer.appendChild(element));
}
}
/* ===========================================
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();
});