forked from izu/student-web-if-development-kit
refactor(tapops): modularize page code
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user