diff --git a/groups/Tapops/css/base-scoped.css b/groups/Tapops/css/base-scoped.css new file mode 100644 index 0000000..7316c29 --- /dev/null +++ b/groups/Tapops/css/base-scoped.css @@ -0,0 +1,123 @@ +/* =========================================== + Base / Reset — scoped under .tapops-doc-root + Prevents contaminating Tailwind nav/footer. + =========================================== */ + +.tapops-doc-root *, +.tapops-doc-root *::before, +.tapops-doc-root *::after { + box-sizing: border-box; +} + +/* Font is set on the root only (inherited), NOT on `*`. + A universal `.tapops-doc-root *` rule would tie Font Awesome's + `.fas` font-family in specificity and—loading later—override it, + blanking every icon. Inheritance loses to FA's explicit rule, so + icons render while normal text still gets Roboto. */ +.tapops-doc-root { + font-family: 'Roboto', sans-serif; + color: var(--text-dark); + line-height: 1.6; +} + +/* Form controls don't inherit font-family by default — opt them in. + These elements never hold Font Awesome glyphs, so this is icon-safe. */ +.tapops-doc-root input, +.tapops-doc-root select, +.tapops-doc-root textarea, +.tapops-doc-root button { + font-family: inherit; +} + +.tapops-doc-root a { + text-decoration: none; + color: inherit; +} + +.tapops-doc-root img { + display: block; +} + +/* --- Shared Section Styles --- */ + +.tapops-doc-root .section-header { + margin-bottom: 50px; +} + +.tapops-doc-root .section-tag { + font-size: 0.75rem; + font-weight: 800; + letter-spacing: 2px; + background-color: var(--secondary-gold); + color: var(--primary-blue); + display: inline-block; + padding: 6px 14px; + text-transform: uppercase; + margin-top: 10px; +} + +.tapops-doc-root .section-title { + font-size: 2.5rem; + font-weight: 800; + color: var(--primary-blue); + letter-spacing: -1px; + line-height: 1.15; +} + +/* --- Shared Button Styles --- */ + +.tapops-doc-root .btn-join, +.tapops-doc-root .btn-outline { + padding: 17px 30px; + font-weight: 800; + font-size: 0.9rem; + letter-spacing: 1px; + display: inline-flex; + align-items: center; + gap: 12px; + transition: all 0.3s ease; +} + +.tapops-doc-root .btn-join { + background-color: var(--secondary-gold); + color: var(--primary-blue); +} + +.tapops-doc-root .btn-outline { + border: 1px solid rgba(255, 255, 255, 0.18); + color: white; +} + +.tapops-doc-root .btn-join:hover, +.tapops-doc-root .btn-outline:hover { + transform: translateY(-2px); +} + +.tapops-doc-root .btn-join:hover { + background-color: #fff; + box-shadow: 0 15px 30px rgba(0, 0, 0, 0.25); +} + +.tapops-doc-root .btn-outline:hover { + border-color: var(--secondary-gold); + color: var(--secondary-gold); +} + +@media (max-width: 768px) { + .tapops-doc-root .btn-join, + .tapops-doc-root .btn-outline { + padding: 14px 24px; + font-size: 0.82rem; + width: 100%; + justify-content: center; + } +} + +@media (max-width: 480px) { + .tapops-doc-root .btn-join, + .tapops-doc-root .btn-outline { + padding: 12px 20px; + font-size: 0.78rem; + letter-spacing: 0.5px; + } +} diff --git a/groups/Tapops/css/documents.css b/groups/Tapops/css/documents.css new file mode 100644 index 0000000..b0e6e35 --- /dev/null +++ b/groups/Tapops/css/documents.css @@ -0,0 +1 @@ +@import url("repository/index.css"); diff --git a/groups/Tapops/css/hero.css b/groups/Tapops/css/hero.css new file mode 100644 index 0000000..0dd8092 --- /dev/null +++ b/groups/Tapops/css/hero.css @@ -0,0 +1,276 @@ +/* =========================================== + Hero Section (Centered & Immersive) — Tapops + =========================================== */ + +.research-hero.immersive-hero { + background: radial-gradient(circle at center, #003150 0%, #001e35 100%); + color: white; + padding: 160px 5% 180px; + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding-bottom: 200px; +} + +.research-hero::before { + content: ''; + position: absolute; + width: 800px; + height: 800px; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + + background: conic-gradient(from 180deg at 50% 50%, + transparent 0deg, + rgba(254, 180, 1, 0.15) 180deg, + transparent 360deg); + + -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #fff 0); + mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #fff 0); + + border-radius: 50%; + pointer-events: none; + z-index: 1; + animation: rotate-orbit 30s linear infinite; +} + +@keyframes rotate-orbit { + from { + transform: translate(-50%, -50%) rotate(0deg); + } + + to { + transform: translate(-50%, -50%) rotate(360deg); + } +} + +.hero-container-centered { + max-width: 900px; + position: relative; + z-index: 2; + width: 100%; + padding: 0 16px; +} + +.hero-kicker { + font-size: 0.85rem; + letter-spacing: 4px; + font-weight: 800; + color: var(--secondary-gold); + text-transform: uppercase; + margin-bottom: 24px; + display: inline-block; +} + +.hero-title { + font-size: 4.8rem; + font-weight: 800; + line-height: 1.05; + letter-spacing: -3px; + margin-bottom: 30px; +} + +.hero-tagline { + font-size: 1.25rem; + line-height: 1.6; + opacity: 0.85; + margin-bottom: 45px; +} + +.hero-actions { + display: flex; + justify-content: center; + gap: 20px; +} + +/* --- Floating Insight Bar --- */ + +.hero-floating-stat-bar { + position: absolute; + bottom: 50px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + background: rgba(255, 255, 255, 0.03); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 25px 60px; + border-radius: 4px; + z-index: 3; + gap: 40px; + box-shadow: 0 30px 60px rgba(0, 0, 0, 0.3); + max-width: calc(100% - 32px); + box-sizing: border-box; +} + +.stat-bar-item { + display: flex; + flex-direction: column; + align-items: center; + white-space: nowrap; +} + +.stat-bar-value { + font-size: 2.2rem; + font-weight: 800; + color: var(--secondary-gold); + line-height: 1.1; +} + +.stat-bar-label { + font-size: 0.7rem; + letter-spacing: 1.5px; + text-transform: uppercase; + font-weight: 700; + opacity: 0.6; + margin-top: 4px; +} + +.stat-bar-divider { + width: 1px; + height: 40px; + background: rgba(255, 255, 255, 0.1); +} + +/* =========================================== + Hero Responsive + =========================================== */ + +@media (max-width: 992px) { + .research-hero.immersive-hero { + padding: 120px 5% 180px; + } + + .hero-title { + font-size: 3.5rem; + letter-spacing: -2px; + } + + .hero-tagline { + font-size: 1.1rem; + } + + .hero-floating-stat-bar { + padding: 20px 32px; + gap: 24px; + } + + .stat-bar-value { + font-size: 1.6rem; + } +} + +@media (max-width: 768px) { + .research-hero.immersive-hero { + padding: 100px 16px 130px; + } + + .hero-kicker { + font-size: 0.68rem; + letter-spacing: 2.5px; + margin-bottom: 16px; + } + + .hero-title { + font-size: 2.2rem; + letter-spacing: -1px; + margin-bottom: 18px; + } + + .hero-tagline { + font-size: 0.9rem; + line-height: 1.5; + margin-bottom: 28px; + } + + .hero-actions { + flex-direction: column; + align-items: stretch; + gap: 12px; + width: 100%; + max-width: 320px; + margin: 0 auto; + } + + .hero-floating-stat-bar { + width: 100%; + max-width: 320px; + padding: 18px 24px; + gap: 20px; + bottom: 30px; + } + + .stat-bar-value { + font-size: 1.3rem; + } + + .stat-bar-label { + font-size: 0.6rem; + letter-spacing: 1px; + } + + .stat-bar-divider { + height: 30px; + } + + .research-hero::before { + width: 400px; + height: 400px; + } +} + +@media (max-width: 480px) { + .research-hero.immersive-hero { + padding: 80px 12px 125px; + } + + .hero-kicker { + font-size: 0.6rem; + letter-spacing: 2px; + } + + .hero-title { + font-size: 1.8rem; + letter-spacing: -0.5px; + margin-bottom: 14px; + } + + .hero-tagline { + font-size: 0.82rem; + line-height: 1.45; + margin-bottom: 22px; + } + + .hero-actions { + max-width: 280px; + } + + .hero-floating-stat-bar { + flex-direction: row; + bottom: 20px; + gap: 16px; + padding: 14px 20px; + width: 100%; + max-width: 280px; + justify-content: space-evenly; + } + + .stat-bar-divider { + width: 1px; + height: 24px; + } + + .stat-bar-value { + font-size: 1.2rem; + } + + .research-hero::before { + width: 280px; + height: 280px; + } +} diff --git a/groups/Tapops/css/nav.css b/groups/Tapops/css/nav.css new file mode 100644 index 0000000..c68e1d3 --- /dev/null +++ b/groups/Tapops/css/nav.css @@ -0,0 +1,286 @@ +/* =========================================== + Navigation & Mega Menu Styles + Extracted from inline + + + + + + + + +
+ - - - - - - -
-
-
- Tugas Halaman Statis -

Berkas Penting

-

Kelompok 09 — TAPOPS  ·  Informatika UNTAN 2026

-
-
- -

Tulis konten halaman kelompok di sini.

-

Subjudul

-

Gunakan paragraf, list, tabel, gambar, dan elemen konten lainnya.

- -
-
-
- - - - - - - - + + + - \ No newline at end of file + diff --git a/groups/Tapops/js/app.js b/groups/Tapops/js/app.js new file mode 100644 index 0000000..b8370a2 --- /dev/null +++ b/groups/Tapops/js/app.js @@ -0,0 +1,28 @@ +import { CounterAnimation } from './components/counter-animation.js'; +import { AccessibilityToggleController } from './controllers/accessibility-toggle-controller.js'; +import { DocumentRepository } from './controllers/document-repository.js'; +import { MobileMenuController } from './controllers/mobile-menu-controller.js'; +import { NavigationController } from './controllers/navigation-controller.js'; +import { pageTemplate } from './templates/page-template.js'; + +class TapopsApp { + constructor(root = document.getElementById('tapops-page-root')) { + this.root = root; + } + + init() { + if (!this.root) return; + + this.root.innerHTML = pageTemplate(); + window.applyLang?.(); + window.applyTp?.(this.root); + + new NavigationController().init(); + new MobileMenuController().init(); + new AccessibilityToggleController().init(); + new CounterAnimation().init(); + new DocumentRepository().init(); + } +} + +new TapopsApp().init(); diff --git a/groups/Tapops/js/components/counter-animation.js b/groups/Tapops/js/components/counter-animation.js new file mode 100644 index 0000000..3639b4f --- /dev/null +++ b/groups/Tapops/js/components/counter-animation.js @@ -0,0 +1,44 @@ +export 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); + } +} diff --git a/groups/Tapops/js/components/custom-dropdown.js b/groups/Tapops/js/components/custom-dropdown.js new file mode 100644 index 0000000..a896c36 --- /dev/null +++ b/groups/Tapops/js/components/custom-dropdown.js @@ -0,0 +1,175 @@ +export 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; + } +} diff --git a/groups/Tapops/js/components/date-range-picker.js b/groups/Tapops/js/components/date-range-picker.js new file mode 100644 index 0000000..2a9902d --- /dev/null +++ b/groups/Tapops/js/components/date-range-picker.js @@ -0,0 +1,663 @@ +export 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}`; + } +} diff --git a/groups/Tapops/js/components/document-card-view.js b/groups/Tapops/js/components/document-card-view.js new file mode 100644 index 0000000..25e2901 --- /dev/null +++ b/groups/Tapops/js/components/document-card-view.js @@ -0,0 +1,74 @@ +import { DirectusClient } from '../services/directus-client.js'; +import { FileDownloader } from '../services/file-downloader.js'; +import { DocumentFormatter } from '../utils/document-formatter.js'; + +export 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.fileInfo = DocumentFormatter.fileInfoFromItem(item); + this.fileType = this.fileInfo.type; + this.assetUrl = DirectusClient.assetUrl(item.file); + this.viewerUrl = DirectusClient.viewerUrl(item); + this.downloadName = DocumentFormatter.downloadFilenameFromItem(item); + 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.fileInfo.css; + const iconClass = this.fileInfo.icon; + const formattedDate = DocumentFormatter.formatDate(this.dateIso); + + const safeTitle = DocumentFormatter.escapeHtml(this.title); + const safeViewerUrl = DocumentFormatter.escapeAttr(this.viewerUrl); + const safeAssetUrl = DocumentFormatter.escapeAttr(this.assetUrl); + + // Build tag badges HTML. + const tagBadgesHtml = this.tags.length + ? `
${this.tags.map(t => `${DocumentFormatter.escapeHtml(DocumentFormatter.formatTag(t))}`).join('')}
` + : ''; + + card.innerHTML = ` +
+

${safeTitle}

+
+ ${typeUpper} + ${formattedDate} +
+ ${tagBadgesHtml} +
+ Buka Berkas + Unduh Berkas +
+ `; + + 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; + } +} diff --git a/groups/Tapops/js/controllers/accessibility-toggle-controller.js b/groups/Tapops/js/controllers/accessibility-toggle-controller.js new file mode 100644 index 0000000..e2209c3 --- /dev/null +++ b/groups/Tapops/js/controllers/accessibility-toggle-controller.js @@ -0,0 +1,22 @@ +export class AccessibilityToggleController { + constructor({ + triggerSelector = '[data-accessibility-toggle]', + panelId = 'access-panel', + } = {}) { + this.triggers = Array.from(document.querySelectorAll(triggerSelector)); + this.panel = document.getElementById(panelId); + } + + init() { + if (this.triggers.length === 0) return; + this.triggers.forEach(trigger => { + trigger.addEventListener('click', () => this.toggle()); + }); + } + + toggle() { + if (!this.panel) return; + this.panel.classList.toggle('hidden'); + this.panel.classList.toggle('flex'); + } +} diff --git a/groups/Tapops/js/controllers/document-repository.js b/groups/Tapops/js/controllers/document-repository.js new file mode 100644 index 0000000..9d7f851 --- /dev/null +++ b/groups/Tapops/js/controllers/document-repository.js @@ -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 + + + +
+
+ Periode Unggahan + + +
+ +
+
+
+
+ +
+ - +
+ +
+
+
+ +
+
+
+ +
+
+ Mei +
+ + +
+
+
+ Juni +
+ + +
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+ RENTANG WAKTU +
+ + + + + + + + + + +
+
+
+ + +
+
+ +
+ Urutkan +
+
+ +
+ Kategori Berkas +
+
+
+ + + +
+
+ + Menghubungkan ke repositori berkas... +
+
+ + + + + +
+
+
+

Tata Cara Pengajuan Berkas

+ +
+ +
+ +
+
+
+
01
+
+
+
+

Unduh Formulir

+

Pilih kategori pengajuan Anda, lalu unduh formulir versi terbaru. Pastikan kode revisi pada formulir sesuai dengan yang tertera di halaman ini.

+
+
+ + +
+
+
+
02
+
+
+
+

Lengkapi Formulir

+

Isi setiap kolom dengan lengkap dan teliti. Sertakan seluruh dokumen pendukung yang diminta sebelum pengiriman.

+
+
+ + +
+
+
+
03
+
+
+

Kirim Berkas

+

Kirimkan formulir beserta dokumen pendukung melalui email resmi atau WhatsApp admin prodi. Berkas yang lengkap akan langsung diproses oleh admin prodi.

+
+
+
+
+
+ + +
+
+
+

Butuh Bantuan Lebih Lanjut?

+

Tim admin kami siap membantu Anda melalui kanal komunikasi resmi di bawah ini.

+
+ +
+
+
+
+

Korespondensi Resmi (Email)

+

Gunakan untuk permohonan form khusus non-publik atau administrasi formal lainnya.

+
    +
  • Respon Maks. 24 Jam
  • +
  • Pelayanan Formal
  • +
+ HUBUNGI ADMIN +
+
+ +
+
+
+

Quick Support (WhatsApp)

+

Konsultasi cepat alur birokrasi atau pelaporan kendala akses pada berkas repositori.

+
    +
  • Respon Instan Jam Kerja
  • +
  • Konsultasi Teknis
  • +
+ CHAT WHATSAPP +
+
+
+
+
+ +`; +} diff --git a/groups/Tapops/js/templates/navigation-template.js b/groups/Tapops/js/templates/navigation-template.js new file mode 100644 index 0000000..af23dbd --- /dev/null +++ b/groups/Tapops/js/templates/navigation-template.js @@ -0,0 +1,843 @@ +export function navigationTemplate() { + return ` + + + + `; +} diff --git a/groups/Tapops/js/templates/page-template.js b/groups/Tapops/js/templates/page-template.js new file mode 100644 index 0000000..95d6ddf --- /dev/null +++ b/groups/Tapops/js/templates/page-template.js @@ -0,0 +1,7 @@ +import { navigationTemplate } from './navigation-template.js'; +import { mainContentTemplate } from './main-content-template.js'; +import { footerTemplate } from './footer-template.js'; + +export function pageTemplate() { + return [navigationTemplate(), mainContentTemplate(), footerTemplate()].join(''); +} diff --git a/groups/Tapops/js/utils/date-range-calculator.js b/groups/Tapops/js/utils/date-range-calculator.js new file mode 100644 index 0000000..7e532bd --- /dev/null +++ b/groups/Tapops/js/utils/date-range-calculator.js @@ -0,0 +1,112 @@ +export class DateRangeCalculator { + static clearTime(date) { + if (!date) return null; + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; + } + + static datesEqual(left, right) { + if (!left || !right) return false; + return left.getFullYear() === right.getFullYear() + && left.getMonth() === right.getMonth() + && left.getDate() === right.getDate(); + } + + static calculatePreset(presetName, todayInput = new Date()) { + const today = this.clearTime(todayInput); + 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 day = temp.getDay(); + const diff = temp.getDate() - day + (day === 0 ? -6 : 1); + start = new Date(temp); + start.setDate(diff - 7); + end = new Date(temp); + end.setDate(diff - 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 }; + } + + static formatDisplay(date) { + if (!date) return ''; + const d = new Date(date); + const day = String(d.getDate()).padStart(2, '0'); + const month = String(d.getMonth() + 1).padStart(2, '0'); + return `${day}/${month}/${d.getFullYear()}`; + } + + static parseDisplayDate(value) { + const match = String(value || '').trim().match(/^(\d{1,2})[\/\-.](\d{1,2})[\/\-.](\d{4})$/); + if (!match) return null; + + const day = Number.parseInt(match[1], 10); + const month = Number.parseInt(match[2], 10) - 1; + const year = Number.parseInt(match[3], 10); + if (month < 0 || month > 11 || day < 1 || day > 31 || year < 1000 || year > 9999) return null; + + const date = new Date(year, month, day); + if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) return null; + return date; + } + + static toDateKey(date) { + if (!date) return ''; + const d = new Date(date); + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } +} diff --git a/groups/Tapops/js/utils/document-formatter.js b/groups/Tapops/js/utils/document-formatter.js new file mode 100644 index 0000000..8d6be2e --- /dev/null +++ b/groups/Tapops/js/utils/document-formatter.js @@ -0,0 +1,204 @@ +export class DocumentFormatter { + static MONTHS_ID = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des']; + + 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 TAG_ABBREVIATIONS = new Set(['kp', 'sop', 'ta', 'krs', 'khs', 'sks', 'pkl', 'mbkm']); + + static FILE_TYPE_RULES = [ + { extensions: ['pdf'], mimeTypes: ['application/pdf'], icon: 'fas fa-file-pdf', css: 'doc-type-pdf' }, + { + extensions: ['doc', 'docx'], + mimeTypes: [ + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ], + fallbackType: 'docx', + icon: 'fas fa-file-word', + css: 'doc-type-docx', + }, + { + extensions: ['ppt', 'pptx'], + mimeTypes: [ + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + ], + fallbackType: 'pptx', + icon: 'fas fa-file-powerpoint', + css: 'doc-type-pptx', + }, + { + extensions: ['xls', 'xlsx'], + mimeTypes: [ + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ], + fallbackType: 'xlsx', + icon: 'fas fa-file-excel', + css: 'doc-type-xlsx', + }, + { extensions: ['csv'], mimeTypes: ['text/csv'], icon: 'fas fa-file-csv', css: 'doc-type-xlsx' }, + { extensions: ['txt', 'rtf', 'md'], mimeTypes: ['text/plain', 'application/rtf'], icon: 'fas fa-file-alt', css: 'doc-type-txt' }, + { + extensions: ['zip', 'rar', '7z', 'tar', 'gz'], + mimeTypes: ['application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed', 'application/gzip'], + icon: 'fas fa-file-archive', + css: 'doc-type-archive', + }, + { + extensions: ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'], + mimePrefix: 'image/', + icon: 'fas fa-file-image', + css: 'doc-type-image', + }, + ]; + + 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()}`; + } + + static categoryFromTags(tags) { + if (!tags || tags.length === 0) return 'umum'; + const lower = tags.map(tag => tag.toLowerCase()); + for (const rule of this.CATEGORY_RULES) { + if (rule.match.some(match => lower.includes(match))) return rule.key; + } + return 'umum'; + } + + static fileTypeFromName(name) { + return this.fileInfoFromName(name).type; + } + + static fileInfoFromName(name) { + const extension = this.extensionFromName(name); + const rule = this.FILE_TYPE_RULES.find(item => item.extensions.includes(extension)); + + if (!rule) { + return { + type: extension || 'file', + icon: 'fas fa-file', + css: 'doc-type-file', + }; + } + + return { + type: extension, + icon: rule.icon, + css: rule.css, + }; + } + + static fileInfoFromItem(item) { + const file = item?.file && typeof item.file === 'object' ? item.file : {}; + const nameCandidates = [ + file.filename_download, + file.filename_disk, + item?.nama_berkas, + ]; + + for (const name of nameCandidates) { + const info = this.fileInfoFromName(name); + if (info.type !== 'file') return info; + } + + return this.fileInfoFromMime(file.type); + } + + static fileInfoFromMime(type) { + const mime = String(type || '').trim().toLowerCase(); + if (!mime) return this.fileInfoFromName(''); + + const rule = this.FILE_TYPE_RULES.find(item => ( + item.mimeTypes?.includes(mime) || (item.mimePrefix && mime.startsWith(item.mimePrefix)) + )); + + if (!rule) return this.fileInfoFromName(''); + + return { + type: rule.fallbackType || rule.extensions[0], + icon: rule.icon, + css: rule.css, + }; + } + + static extensionFromName(name) { + const normalized = String(name || '').trim().toLowerCase(); + const lastSegment = normalized.split(/[\\/]/).pop() || ''; + const extensionStart = lastSegment.lastIndexOf('.'); + + if (extensionStart <= 0 || extensionStart === lastSegment.length - 1) return ''; + return lastSegment.slice(extensionStart + 1); + } + + static downloadFilename(name, type) { + const ext = `.${type}`; + if (!name) return `dokumen${ext}`; + return name.toLowerCase().endsWith(ext.toLowerCase()) ? name : `${name}${ext}`; + } + + static downloadFilenameFromItem(item) { + const file = item?.file && typeof item.file === 'object' ? item.file : {}; + const originalFilename = file.filename_download || file.filename_disk; + if (originalFilename) return originalFilename; + + const fileType = this.fileInfoFromItem(item).type; + return this.downloadFilename(item?.nama_berkas || 'dokumen', fileType); + } + + static escapeHtml(str) { + if (str == null) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + static escapeAttr(str) { + return this.escapeHtml(str); + } + + static formatTag(tag) { + const value = String(tag).trim().replace(/\s+/g, ' '); + if (!value) return value; + + return value.split(' ').map(word => { + const lower = word.toLowerCase(); + if (this.TAG_ABBREVIATIONS.has(lower) || /^[A-Z0-9]{2,}$/.test(word)) { + return lower.toUpperCase(); + } + + return lower.charAt(0).toUpperCase() + lower.slice(1); + }).join(' '); + } + + static parseTags(raw) { + if (!raw) return []; + const str = String(raw).trim(); + if (!str) return []; + + if (str.startsWith('[')) { + try { + const arr = JSON.parse(str); + if (Array.isArray(arr)) { + return arr.map(tag => String(tag).trim()).filter(Boolean); + } + } catch { + // Fall through to comma-separated parsing. + } + } + + return str.split(',').map(tag => tag.trim()).filter(Boolean); + } +} diff --git a/groups/Tapops/js/viewer.js b/groups/Tapops/js/viewer.js new file mode 100644 index 0000000..f4c57c5 --- /dev/null +++ b/groups/Tapops/js/viewer.js @@ -0,0 +1 @@ +import './viewer/viewer-app.js'; diff --git a/groups/Tapops/js/viewer/file-viewer.js b/groups/Tapops/js/viewer/file-viewer.js new file mode 100644 index 0000000..dd90530 --- /dev/null +++ b/groups/Tapops/js/viewer/file-viewer.js @@ -0,0 +1,122 @@ +import { FileDownloader } from '../services/file-downloader.js'; +import { ViewerApi } from './viewer-api.js'; + +export class FileViewer { + constructor() { + const params = new URLSearchParams(window.location.search); + this.id = params.get('id'); + this.name = params.get('name') || 'Dokumen'; + this.type = (params.get('type') || 'pdf').toLowerCase(); + + this.titleEl = document.getElementById('viewer-title'); + this.typeBadgeEl = document.getElementById('viewer-type'); + this.downloadEl = document.getElementById('viewer-download'); + this.contentEl = document.getElementById('viewer-content'); + + this.assetUrl = this.id ? ViewerApi.assetUrl(this.id) : null; + } + + async init() { + if (!this.id) { + this._renderError('Parameter berkas tidak ditemukan di URL.'); + return; + } + + document.title = `${this.name} — Pratinjau Dokumen`; + this.titleEl.textContent = this.name; + this.typeBadgeEl.textContent = this.type.toUpperCase(); + this._setupDownload(); + + try { + if (this._isOfficeType()) { + this._renderOfficeOnline(); + } else if (this.type === 'pdf') { + this._renderPdf(); + } else { + this._renderUnsupported(); + } + } catch (err) { + console.error(err); + this._renderError('Gagal memuat pratinjau berkas. Silakan unduh berkas untuk membuka secara lokal.'); + } + } + + _setupDownload() { + const ext = `.${this.type}`; + const filename = this.name.toLowerCase().endsWith(ext) ? this.name : `${this.name}${ext}`; + + this.downloadEl.href = FileDownloader.originalDownloadUrl(this.assetUrl); + this.downloadEl.download = filename; + this.downloadEl.style.cursor = 'pointer'; + this.downloadEl.addEventListener('click', async event => { + event.preventDefault(); + const originalHtml = this.downloadEl.innerHTML; + this.downloadEl.innerHTML = ' Mengunduh…'; + this.downloadEl.style.pointerEvents = 'none'; + try { + await FileDownloader.download(this.assetUrl, filename); + } catch (err) { + console.warn('Blob download failed, falling back:', err); + window.open(FileDownloader.originalDownloadUrl(this.assetUrl), '_blank'); + } finally { + this.downloadEl.innerHTML = originalHtml; + this.downloadEl.style.pointerEvents = ''; + } + }); + } + + _isOfficeType() { + return ['doc', 'docx', 'ppt', 'pptx', 'xls', 'xlsx'].includes(this.type); + } + + _renderOfficeOnline() { + const iframe = document.createElement('iframe'); + iframe.className = 'viewer-office-frame'; + iframe.title = this.name; + iframe.src = `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(this.assetUrl)}`; + iframe.setAttribute('frameborder', '0'); + iframe.setAttribute('allowfullscreen', ''); + this.contentEl.replaceChildren(iframe); + } + + _renderPdf() { + const iframe = document.createElement('iframe'); + iframe.className = 'viewer-pdf-frame'; + iframe.title = this.name; + iframe.src = this.assetUrl; + this.contentEl.replaceChildren(iframe); + } + + _renderUnsupported() { + const wrapper = document.createElement('div'); + wrapper.className = 'viewer-error'; + + const icon = document.createElement('i'); + icon.className = 'fas fa-file'; + + const span = document.createElement('span'); + span.textContent = `Pratinjau tidak tersedia untuk format ${this.type.toUpperCase()}. Silakan unduh berkas.`; + + wrapper.append(icon, span); + this.contentEl.replaceChildren(wrapper); + } + + _renderError(message) { + const wrapper = document.createElement('div'); + wrapper.className = 'viewer-error'; + + const icon = document.createElement('i'); + icon.className = 'fas fa-exclamation-triangle'; + + const span = document.createElement('span'); + span.textContent = message; + + const back = document.createElement('a'); + back.href = 'index.html#repository'; + back.className = 'btn-back-mini'; + back.textContent = 'Kembali ke Repositori'; + + wrapper.append(icon, span, back); + this.contentEl.replaceChildren(wrapper); + } +} diff --git a/groups/Tapops/js/viewer/viewer-api.js b/groups/Tapops/js/viewer/viewer-api.js new file mode 100644 index 0000000..6330217 --- /dev/null +++ b/groups/Tapops/js/viewer/viewer-api.js @@ -0,0 +1,31 @@ +export class ViewerApi { + static API_BASE = 'https://api.ifuntanhub.dev'; + + static assetUrl(uuid) { + return `${this.API_BASE}/assets/${uuid}`; + } + + static async fetchBlob(url, onProgress) { + const response = await fetch(url); + if (!response.ok) throw new Error(`Gagal mengambil berkas (HTTP ${response.status}).`); + + const contentLength = response.headers.get('Content-Length'); + if (onProgress && contentLength && response.body) { + const total = Number.parseInt(contentLength, 10); + const reader = response.body.getReader(); + const chunks = []; + let received = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + received += value.length; + onProgress(received / total); + } + return new Blob(chunks); + } + + return response.blob(); + } +} diff --git a/groups/Tapops/js/viewer/viewer-app.js b/groups/Tapops/js/viewer/viewer-app.js new file mode 100644 index 0000000..fee47e6 --- /dev/null +++ b/groups/Tapops/js/viewer/viewer-app.js @@ -0,0 +1,8 @@ +import { FileViewer } from './file-viewer.js'; +import { ViewerPrefetch } from './viewer-prefetch.js'; + +ViewerPrefetch.fromLocation(); + +document.addEventListener('DOMContentLoaded', () => { + new FileViewer().init(); +}); diff --git a/groups/Tapops/js/viewer/viewer-prefetch.js b/groups/Tapops/js/viewer/viewer-prefetch.js new file mode 100644 index 0000000..f2771fc --- /dev/null +++ b/groups/Tapops/js/viewer/viewer-prefetch.js @@ -0,0 +1,16 @@ +import { ViewerApi } from './viewer-api.js'; + +export class ViewerPrefetch { + static fromLocation(location = window.location) { + const params = new URLSearchParams(location.search); + const id = params.get('id'); + if (!id) return; + + const link = document.createElement('link'); + link.rel = 'prefetch'; + link.as = 'fetch'; + link.crossOrigin = 'anonymous'; + link.href = ViewerApi.assetUrl(id); + document.head.appendChild(link); + } +} diff --git a/groups/Tapops/tests/date-pagination.test.mjs b/groups/Tapops/tests/date-pagination.test.mjs new file mode 100644 index 0000000..91a7488 --- /dev/null +++ b/groups/Tapops/tests/date-pagination.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { DateRangeCalculator } from '../js/utils/date-range-calculator.js'; +import { PaginationModel } from '../js/models/pagination-model.js'; + +test('calculates date presets using an injected current day', () => { + const today = new Date('2026-06-04T12:00:00+07:00'); + + const last7 = DateRangeCalculator.calculatePreset('last7', today); + assert.equal(DateRangeCalculator.toDateKey(last7.start), '2026-05-29'); + assert.equal(DateRangeCalculator.toDateKey(last7.end), '2026-06-04'); + + const thisMonth = DateRangeCalculator.calculatePreset('thisMonth', today); + assert.equal(DateRangeCalculator.toDateKey(thisMonth.start), '2026-06-01'); + assert.equal(DateRangeCalculator.toDateKey(thisMonth.end), '2026-06-04'); + + const all = DateRangeCalculator.calculatePreset('all', today); + assert.equal(all.start, null); + assert.equal(all.end, null); +}); + +test('formats and parses dd/mm/yyyy date input safely', () => { + const date = new Date(2026, 5, 4); + assert.equal(DateRangeCalculator.formatDisplay(date), '04/06/2026'); + assert.equal(DateRangeCalculator.toDateKey(date), '2026-06-04'); + assert.equal(DateRangeCalculator.parseDisplayDate('04/06/2026')?.getFullYear(), 2026); + assert.equal(DateRangeCalculator.parseDisplayDate('31/02/2026'), null); + assert.equal(DateRangeCalculator.parseDisplayDate('04/06/10000'), null); +}); + +test('creates stable compact pagination ranges', () => { + assert.deepEqual(PaginationModel.range(1, 4), [1, 2, 3, 4]); + assert.deepEqual(PaginationModel.range(1, 10), [1, 2, '...', 10]); + assert.deepEqual(PaginationModel.range(5, 10), [1, '...', 4, 5, 6, '...', 10]); + assert.deepEqual(PaginationModel.range(10, 10), [1, '...', 9, 10]); +}); diff --git a/groups/Tapops/tests/directus-client.test.mjs b/groups/Tapops/tests/directus-client.test.mjs new file mode 100644 index 0000000..16dbbf6 --- /dev/null +++ b/groups/Tapops/tests/directus-client.test.mjs @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { DirectusClient } from '../js/services/directus-client.js'; + +test('requests berkas with expanded Directus file metadata', () => { + const url = new URL(DirectusClient.itemsUrl('berkas')); + + assert.equal(url.pathname, '/items/berkas'); + assert.equal(url.searchParams.get('limit'), '-1'); + assert.equal(url.searchParams.get('fields'), '*,file.id,file.filename_download,file.type,file.filename_disk'); +}); + +test('builds asset and viewer URLs from expanded Directus file objects', () => { + const item = { + nama_berkas: 'Panduan Penulisan Proposal Kerja Praktek', + file: { + id: '4976330e-e547-45f6-b9be-afce493c8a82', + filename_download: 'Panduan Penulisan Proposal Kerja Praktek.docx', + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }, + }; + + assert.equal( + DirectusClient.assetUrl(item.file), + 'https://api.ifuntanhub.dev/assets/4976330e-e547-45f6-b9be-afce493c8a82', + ); + + const viewerUrl = new URL(DirectusClient.viewerUrl(item), 'https://example.test/groups/Tapops/'); + assert.equal(viewerUrl.pathname, '/groups/Tapops/viewer.html'); + assert.equal(viewerUrl.searchParams.get('id'), '4976330e-e547-45f6-b9be-afce493c8a82'); + assert.equal(viewerUrl.searchParams.get('name'), 'Panduan Penulisan Proposal Kerja Praktek'); + assert.equal(viewerUrl.searchParams.get('type'), 'docx'); +}); diff --git a/groups/Tapops/tests/document-formatter.test.mjs b/groups/Tapops/tests/document-formatter.test.mjs new file mode 100644 index 0000000..9933fe8 --- /dev/null +++ b/groups/Tapops/tests/document-formatter.test.mjs @@ -0,0 +1,111 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { DocumentFormatter } from '../js/utils/document-formatter.js'; + +test('parses comma-separated and JSON string tags', () => { + assert.deepEqual(DocumentFormatter.parseTags('akademik, panduan, 2026'), [ + 'akademik', + 'panduan', + '2026', + ]); + assert.deepEqual(DocumentFormatter.parseTags('["kerja praktik","kp"]'), [ + 'kerja praktik', + 'kp', + ]); +}); + +test('formats tag labels without breaking abbreviations', () => { + assert.equal(DocumentFormatter.formatTag('SOP'), 'SOP'); + assert.equal(DocumentFormatter.formatTag('kp'), 'KP'); + assert.equal(DocumentFormatter.formatTag('kerja praktek'), 'Kerja Praktek'); + assert.equal(DocumentFormatter.formatTag('Kerja Praktek'), 'Kerja Praktek'); + assert.equal(DocumentFormatter.formatTag('umum'), 'Umum'); +}); + +test('maps parsed tags to fixed repository categories', () => { + assert.equal(DocumentFormatter.categoryFromTags(['TA']), 'skripsi'); + assert.equal(DocumentFormatter.categoryFromTags(['Kalender']), 'akademik'); + assert.equal(DocumentFormatter.categoryFromTags(['kp']), 'praktik'); + assert.equal(DocumentFormatter.categoryFromTags(['surat']), 'umum'); + assert.equal(DocumentFormatter.categoryFromTags([]), 'umum'); +}); + +test('derives file type and safe download filename from document name', () => { + assert.equal(DocumentFormatter.fileTypeFromName('Panduan Akademik.pdf'), 'pdf'); + assert.equal(DocumentFormatter.fileTypeFromName('Form KP.DOCX'), 'docx'); + assert.equal(DocumentFormatter.fileTypeFromName('Catatan.txt'), 'txt'); + assert.equal(DocumentFormatter.fileTypeFromName('Materi Seminar.pptx'), 'pptx'); + assert.equal(DocumentFormatter.downloadFilename('Form KP', 'docx'), 'Form KP.docx'); + assert.equal(DocumentFormatter.downloadFilename('Template lama.DOC', 'doc'), 'Template lama.DOC'); + assert.equal(DocumentFormatter.downloadFilename('Kalender.pdf', 'pdf'), 'Kalender.pdf'); +}); + +test('maps supported file names to display metadata for cards', () => { + assert.deepEqual(DocumentFormatter.fileInfoFromName('Panduan Akademik.pdf'), { + type: 'pdf', + icon: 'fas fa-file-pdf', + css: 'doc-type-pdf', + }); + assert.deepEqual(DocumentFormatter.fileInfoFromName('Form KP.DOCX'), { + type: 'docx', + icon: 'fas fa-file-word', + css: 'doc-type-docx', + }); + assert.deepEqual(DocumentFormatter.fileInfoFromName('Catatan.txt'), { + type: 'txt', + icon: 'fas fa-file-alt', + css: 'doc-type-txt', + }); + assert.deepEqual(DocumentFormatter.fileInfoFromName('Materi Seminar.pptx'), { + type: 'pptx', + icon: 'fas fa-file-powerpoint', + css: 'doc-type-pptx', + }); + assert.deepEqual(DocumentFormatter.fileInfoFromName('Lampiran.7z'), { + type: '7z', + icon: 'fas fa-file-archive', + css: 'doc-type-archive', + }); +}); + +test('derives file metadata from expanded Directus file records when display title has no extension', () => { + const pdfItem = { + nama_berkas: 'SOP Kerja Praktek', + file: { + filename_download: 'SOP Kerja Praktek.pdf', + type: 'application/pdf', + }, + }; + const docxItem = { + nama_berkas: 'Panduan Penulisan Proposal Kerja Praktek', + file: { + filename_download: 'Panduan Penulisan Proposal Kerja Praktek.docx', + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }, + }; + + assert.deepEqual(DocumentFormatter.fileInfoFromItem(pdfItem), { + type: 'pdf', + icon: 'fas fa-file-pdf', + css: 'doc-type-pdf', + }); + assert.deepEqual(DocumentFormatter.fileInfoFromItem(docxItem), { + type: 'docx', + icon: 'fas fa-file-word', + css: 'doc-type-docx', + }); + assert.equal(DocumentFormatter.downloadFilenameFromItem(pdfItem), 'SOP Kerja Praktek.pdf'); + assert.equal(DocumentFormatter.downloadFilenameFromItem(docxItem), 'Panduan Penulisan Proposal Kerja Praktek.docx'); +}); + +test('falls back to MIME type when Directus filename metadata has no extension', () => { + assert.deepEqual(DocumentFormatter.fileInfoFromItem({ + nama_berkas: 'Berkas Presentasi', + file: { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' }, + }), { + type: 'pptx', + icon: 'fas fa-file-powerpoint', + css: 'doc-type-pptx', + }); +}); diff --git a/groups/Tapops/tests/file-preview-download.test.mjs b/groups/Tapops/tests/file-preview-download.test.mjs new file mode 100644 index 0000000..25e91b1 --- /dev/null +++ b/groups/Tapops/tests/file-preview-download.test.mjs @@ -0,0 +1,197 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { FileDownloader } from '../js/services/file-downloader.js'; +import { FileViewer } from '../js/viewer/file-viewer.js'; + +class FakeElement { + constructor(tagName = 'div') { + this.tagName = tagName.toUpperCase(); + this.children = []; + this.attributes = new Map(); + this.dataset = {}; + this.eventListeners = new Map(); + this.style = {}; + this.className = ''; + this.href = ''; + this.src = ''; + this.title = ''; + this.textContent = ''; + this.innerHTML = ''; + this.download = ''; + } + + append(...nodes) { + this.children.push(...nodes); + } + + appendChild(node) { + this.children.push(node); + return node; + } + + prepend(node) { + this.children.unshift(node); + return node; + } + + removeChild(node) { + this.children = this.children.filter(child => child !== node); + return node; + } + + replaceChildren(...nodes) { + this.children = [...nodes]; + } + + remove() { + this.removed = true; + } + + setAttribute(name, value) { + this.attributes.set(name, String(value)); + } + + getAttribute(name) { + return this.attributes.get(name); + } + + removeAttribute(name) { + this.attributes.delete(name); + } + + addEventListener(type, handler) { + this.eventListeners.set(type, handler); + } + + click() { + this.clicked = true; + } +} + +function installViewerDom(search) { + const previous = { + document: globalThis.document, + window: globalThis.window, + URL: globalThis.URL, + fetch: globalThis.fetch, + setTimeout: globalThis.setTimeout, + consoleError: console.error, + consoleWarn: console.warn, + }; + const elements = { + title: new FakeElement('h1'), + type: new FakeElement('span'), + download: new FakeElement('a'), + content: new FakeElement('main'), + }; + const body = new FakeElement('body'); + const created = []; + + globalThis.document = { + title: '', + body, + getElementById(id) { + return { + 'viewer-title': elements.title, + 'viewer-type': elements.type, + 'viewer-download': elements.download, + 'viewer-content': elements.content, + }[id]; + }, + createElement(tagName) { + const node = new FakeElement(tagName); + created.push(node); + return node; + }, + }; + globalThis.window = { + location: { search }, + open(url) { + this.openedUrl = url; + }, + }; + globalThis.URL = { + createObjectURL() { + return 'blob:tapops-download'; + }, + revokeObjectURL(url) { + this.revokedUrl = url; + }, + }; + globalThis.setTimeout = callback => { + callback(); + return 1; + }; + console.error = () => {}; + console.warn = () => {}; + + return { + elements, + created, + restore() { + globalThis.document = previous.document; + globalThis.window = previous.window; + globalThis.URL = previous.URL; + globalThis.fetch = previous.fetch; + globalThis.setTimeout = previous.setTimeout; + console.error = previous.consoleError; + console.warn = previous.consoleWarn; + }, + }; +} + +test('repository download fetches the Directus original-file URL', async (t) => { + const { created, restore } = installViewerDom(''); + t.after(restore); + + let fetchedUrl = ''; + globalThis.fetch = async (url) => { + fetchedUrl = url; + return { + ok: true, + blob: async () => new Blob(['docx']), + }; + }; + + await FileDownloader.download('https://api.ifuntanhub.dev/assets/docx-id', 'Form KP.docx'); + + const clickedAnchor = created.find(node => node.tagName === 'A' && node.clicked); + assert.equal(fetchedUrl, 'https://api.ifuntanhub.dev/assets/docx-id?download'); + assert.equal(clickedAnchor.download, 'Form KP.docx'); +}); + +test('DOCX preview renders Microsoft Office Online Viewer iframe', async (t) => { + const { elements, restore } = installViewerDom('?id=docx-id&name=Form%20KP.docx&type=docx'); + t.after(restore); + + await new FileViewer().init(); + + const iframe = elements.content.children[0]; + const assetUrl = 'https://api.ifuntanhub.dev/assets/docx-id'; + assert.equal(iframe.tagName, 'IFRAME'); + assert.equal(iframe.className, 'viewer-office-frame'); + assert.equal(iframe.src, `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(assetUrl)}`); +}); + +test('viewer download uses the Directus original-file URL', async (t) => { + const { elements, created, restore } = installViewerDom('?id=pdf-id&name=Panduan.pdf&type=pdf'); + t.after(restore); + + let fetchedUrl = ''; + globalThis.fetch = async (url) => { + fetchedUrl = url; + return { + ok: true, + headers: { get: () => null }, + blob: async () => new Blob(['pdf']), + }; + }; + + await new FileViewer().init(); + await elements.download.eventListeners.get('click')({ preventDefault() {} }); + + const clickedAnchor = created.find(node => node.tagName === 'A' && node.clicked); + assert.equal(fetchedUrl, 'https://api.ifuntanhub.dev/assets/pdf-id?download'); + assert.equal(clickedAnchor.download, 'Panduan.pdf'); +}); diff --git a/groups/Tapops/tests/static-structure.test.mjs b/groups/Tapops/tests/static-structure.test.mjs new file mode 100644 index 0000000..8a61981 --- /dev/null +++ b/groups/Tapops/tests/static-structure.test.mjs @@ -0,0 +1,22 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const indexHtml = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); +const viewerHtml = readFileSync(new URL('../viewer.html', import.meta.url), 'utf8'); +const repositoryCardsCss = readFileSync(new URL('../css/repository/cards.css', import.meta.url), 'utf8'); + +test('Tapops index uses module scripts and no inline behavior', () => { + assert.match(indexHtml, / + + + diff --git a/package-lock.json b/package-lock.json index a16578c..43e70d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -917,7 +917,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index 31e8272..b0c5938 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,12 @@ "name": "web-student-safe", "version": "1.0.0", "private": true, + "type": "module", "description": "Student-safe static page assignment kit for Informatika UNTAN", "scripts": { "dev": "vite", "build": "vite build", + "test:tapops": "node --test groups/Tapops/tests/*.test.mjs", "preview": "vite preview" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..1d449a0 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,654 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + vite: + specifier: ^7.1.0 + version: 7.3.3 + +packages: + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + +snapshots: + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@types/estree@1.0.8': {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + nanoid@3.3.12: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + vite@7.3.3: + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.60.4 + tinyglobby: 0.2.16 + optionalDependencies: + fsevents: 2.3.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..00f6fc4 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: set this to true or false