forked from izu/student-web-if-development-kit
529693cd5f
* 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.
1600 lines
58 KiB
JavaScript
1600 lines
58 KiB
JavaScript
/**
|
||
* 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, '"')
|
||
.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
|
||
? `<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();
|
||
});
|