Files
student-web-if-development-kit/groups/Tapops/js/controllers/document-repository.js
2026-06-04 18:13:23 +07:00

467 lines
18 KiB
JavaScript
Raw Permalink 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.
import { DirectusClient } from '../services/directus-client.js';
import { DocumentCardView } from '../components/document-card-view.js';
import { CustomDropdown } from '../components/custom-dropdown.js';
import { DateRangePicker } from '../components/date-range-picker.js';
import { CounterAnimation } from '../components/counter-animation.js';
export 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));
}
}