diff --git a/groups/Tapops/css/repository/cards.css b/groups/Tapops/css/repository/cards.css index fdb7f39..f520673 100644 --- a/groups/Tapops/css/repository/cards.css +++ b/groups/Tapops/css/repository/cards.css @@ -35,6 +35,30 @@ color: #3b82f6; } +.doc-type-pptx { + color: #f97316; +} + +.doc-type-xlsx { + color: #16a34a; +} + +.doc-type-txt { + color: #64748b; +} + +.doc-type-archive { + color: #7c3aed; +} + +.doc-type-image { + color: #ec4899; +} + +.doc-type-file { + color: #94a3b8; +} + .doc-title { font-size: 1.15rem; font-weight: 800; @@ -88,7 +112,6 @@ color: hsl(43, 80%, 35%); border: 1px solid hsl(43, 80%, 85%); border-radius: 20px; - text-transform: lowercase; letter-spacing: 0.3px; transition: all 0.2s ease; } diff --git a/groups/Tapops/css/viewer.css b/groups/Tapops/css/viewer.css index 6ec656e..718adb8 100644 --- a/groups/Tapops/css/viewer.css +++ b/groups/Tapops/css/viewer.css @@ -1,5 +1,5 @@ /* =========================================== - Viewer Page — PDF iframe + DOCX rendered preview + Viewer Page — PDF iframe + Office Online preview =========================================== */ .viewer-body { @@ -123,39 +123,14 @@ position: relative; } -.viewer-pdf-frame { +.viewer-pdf-frame, +.viewer-office-frame { flex: 1; width: 100%; border: none; min-height: calc(100vh - 80px); } -.viewer-docx-content { - background: white; - margin: 24px auto; - padding: 60px 80px; - max-width: 900px; - width: calc(100% - 48px); - box-sizing: border-box; - box-shadow: var(--shadow); - border-radius: 4px; - overflow-x: auto; - color: var(--text-dark); - line-height: 1.6; - font-family: 'Roboto', sans-serif; -} - -/* docx-preview injects its own structure; override its defaults gently. */ -.viewer-docx-content .docx-wrapper { - background: transparent !important; - padding: 0 !important; -} - -.viewer-docx-content .docx { - box-shadow: none !important; - margin: 0 !important; -} - /* --- States --- */ .viewer-loading, @@ -183,27 +158,6 @@ color: #ef4444; } -/* --- Download Progress Bar --- */ - -.viewer-progress-bar { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 3px; - background: rgba(255, 255, 255, 0.1); - z-index: 5; - overflow: hidden; -} - -.viewer-progress-fill { - height: 100%; - width: 0%; - background: linear-gradient(90deg, var(--secondary-gold), #ffd54f); - border-radius: 0 2px 2px 0; - transition: width 0.2s ease-out; -} - /* --- Responsive --- */ @media (max-width: 640px) { @@ -220,12 +174,6 @@ font-size: 0.95rem; } - .viewer-docx-content { - padding: 24px; - margin: 12px; - width: calc(100% - 24px); - } - .btn-back, .btn-download { padding: 8px 14px; diff --git a/groups/Tapops/js/components/document-card-view.js b/groups/Tapops/js/components/document-card-view.js index 217daa4..25e2901 100644 --- a/groups/Tapops/js/components/document-card-view.js +++ b/groups/Tapops/js/components/document-card-view.js @@ -8,10 +8,11 @@ export class DocumentCardView { this.title = item.nama_berkas; this.tags = DocumentFormatter.parseTags(item.tag); this.category = DocumentFormatter.categoryFromTags(this.tags); - this.fileType = DocumentFormatter.fileTypeFromName(this.title); + this.fileInfo = DocumentFormatter.fileInfoFromItem(item); + this.fileType = this.fileInfo.type; this.assetUrl = DirectusClient.assetUrl(item.file); this.viewerUrl = DirectusClient.viewerUrl(item); - this.downloadName = DocumentFormatter.downloadFilename(this.title, this.fileType); + this.downloadName = DocumentFormatter.downloadFilenameFromItem(item); this.dateIso = item.tanggal_upload || ''; this.dateKey = this.dateIso ? this.dateIso.substring(0, 10) : ''; } @@ -22,8 +23,8 @@ export class DocumentCardView { card.dataset.date = this.dateKey; const typeUpper = this.fileType.toUpperCase(); - const typeClass = this.fileType === 'pdf' ? 'doc-type-pdf' : 'doc-type-docx'; - const iconClass = this.fileType === 'pdf' ? 'fas fa-file-pdf' : 'fas fa-file-word'; + const typeClass = this.fileInfo.css; + const iconClass = this.fileInfo.icon; const formattedDate = DocumentFormatter.formatDate(this.dateIso); const safeTitle = DocumentFormatter.escapeHtml(this.title); @@ -32,7 +33,7 @@ export class DocumentCardView { // Build tag badges HTML. const tagBadgesHtml = this.tags.length - ? `
${this.tags.map(t => `${DocumentFormatter.escapeHtml(t)}`).join('')}
` + ? `
${this.tags.map(t => `${DocumentFormatter.escapeHtml(DocumentFormatter.formatTag(t))}`).join('')}
` : ''; card.innerHTML = ` diff --git a/groups/Tapops/js/services/directus-client.js b/groups/Tapops/js/services/directus-client.js index 96b682a..69bedd4 100644 --- a/groups/Tapops/js/services/directus-client.js +++ b/groups/Tapops/js/services/directus-client.js @@ -4,17 +4,26 @@ export class DirectusClient { static API_BASE = 'https://api.ifuntanhub.dev'; static itemsUrl(collection) { - return `${this.API_BASE}/items/${collection}?limit=-1`; + const params = new URLSearchParams({ + limit: '-1', + fields: '*,file.id,file.filename_download,file.type,file.filename_disk', + }); + + return `${this.API_BASE}/items/${collection}?${params.toString()}`; } - static assetUrl(uuid) { - return `${this.API_BASE}/assets/${uuid}`; + static fileId(file) { + return file && typeof file === 'object' ? file.id : file; + } + + static assetUrl(file) { + return `${this.API_BASE}/assets/${this.fileId(file)}`; } static viewerUrl(item) { - const type = DocumentFormatter.fileTypeFromName(item.nama_berkas); + const type = DocumentFormatter.fileInfoFromItem(item).type; const params = new URLSearchParams({ - id: item.file, + id: this.fileId(item.file), name: item.nama_berkas, type, }); diff --git a/groups/Tapops/js/services/file-downloader.js b/groups/Tapops/js/services/file-downloader.js index 438a3fb..bc98141 100644 --- a/groups/Tapops/js/services/file-downloader.js +++ b/groups/Tapops/js/services/file-downloader.js @@ -1,7 +1,9 @@ export class FileDownloader { static async download(url, filename) { + const downloadUrl = this.originalDownloadUrl(url); + try { - const response = await fetch(url); + const response = await fetch(downloadUrl); if (!response.ok) throw new Error('Gagal mengunduh berkas.'); const blob = await response.blob(); const blobUrl = URL.createObjectURL(blob); @@ -9,11 +11,21 @@ export class FileDownloader { URL.revokeObjectURL(blobUrl); } catch (err) { console.warn('Unduh via Blob gagal, beralih ke parameter Directus:', err); - const fallbackUrl = url.includes('?') ? `${url}&download` : `${url}?download`; - this._click(fallbackUrl, filename); + this._click(downloadUrl, filename); } } + static originalDownloadUrl(url) { + if (!url) return ''; + + const hashIndex = url.indexOf('#'); + const hash = hashIndex >= 0 ? url.slice(hashIndex) : ''; + const base = hashIndex >= 0 ? url.slice(0, hashIndex) : url; + + if (/(^|[?&])download(?:[=&]|$)/.test(base)) return url; + return `${base}${base.includes('?') ? '&' : '?'}download${hash}`; + } + static _click(href, filename) { const a = document.createElement('a'); a.style.display = 'none'; diff --git a/groups/Tapops/js/utils/document-formatter.js b/groups/Tapops/js/utils/document-formatter.js index 6e6702a..8d6be2e 100644 --- a/groups/Tapops/js/utils/document-formatter.js +++ b/groups/Tapops/js/utils/document-formatter.js @@ -7,6 +7,56 @@ export class DocumentFormatter { { 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); @@ -26,14 +76,83 @@ export class DocumentFormatter { } static fileTypeFromName(name) { - const lower = (name || '').toLowerCase(); - if (lower.endsWith('.docx') || lower.endsWith('.doc')) return 'docx'; - return 'pdf'; + 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}`; - return name.toLowerCase().endsWith(ext) ? name : `${name}${ext}`; + 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) { @@ -50,6 +169,20 @@ export class DocumentFormatter { 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(); diff --git a/groups/Tapops/js/viewer/file-viewer.js b/groups/Tapops/js/viewer/file-viewer.js index db6ca4f..dd90530 100644 --- a/groups/Tapops/js/viewer/file-viewer.js +++ b/groups/Tapops/js/viewer/file-viewer.js @@ -1,6 +1,5 @@ -import { ScriptLoader } from './script-loader.js'; +import { FileDownloader } from '../services/file-downloader.js'; import { ViewerApi } from './viewer-api.js'; -import { ViewerProgressBar } from './viewer-progress-bar.js'; export class FileViewer { constructor() { @@ -29,10 +28,12 @@ export class FileViewer { this._setupDownload(); try { - if (this.type === 'docx') { - await this._renderDocx(); - } else { + if (this._isOfficeType()) { + this._renderOfficeOnline(); + } else if (this.type === 'pdf') { this._renderPdf(); + } else { + this._renderUnsupported(); } } catch (err) { console.error(err); @@ -44,7 +45,8 @@ export class FileViewer { const ext = `.${this.type}`; const filename = this.name.toLowerCase().endsWith(ext) ? this.name : `${this.name}${ext}`; - this.downloadEl.removeAttribute('href'); + this.downloadEl.href = FileDownloader.originalDownloadUrl(this.assetUrl); + this.downloadEl.download = filename; this.downloadEl.style.cursor = 'pointer'; this.downloadEl.addEventListener('click', async event => { event.preventDefault(); @@ -52,19 +54,10 @@ export class FileViewer { this.downloadEl.innerHTML = ' Mengunduh…'; this.downloadEl.style.pointerEvents = 'none'; try { - const blob = await ViewerApi.fetchBlob(this.assetUrl); - const blobUrl = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.style.display = 'none'; - link.href = blobUrl; - link.download = filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(blobUrl); + await FileDownloader.download(this.assetUrl, filename); } catch (err) { console.warn('Blob download failed, falling back:', err); - window.open(`${this.assetUrl}?download`, '_blank'); + window.open(FileDownloader.originalDownloadUrl(this.assetUrl), '_blank'); } finally { this.downloadEl.innerHTML = originalHtml; this.downloadEl.style.pointerEvents = ''; @@ -72,6 +65,20 @@ export class FileViewer { }); } + _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'; @@ -80,33 +87,18 @@ export class FileViewer { this.contentEl.replaceChildren(iframe); } - async _renderDocx() { - const progressBar = new ViewerProgressBar(this.contentEl); - const [blob] = await Promise.all([ - ViewerApi.fetchBlob(this.assetUrl, ratio => progressBar.update(ratio)), - this._loadDocxLibraries(), - ]); - progressBar.finish(); + _renderUnsupported() { + const wrapper = document.createElement('div'); + wrapper.className = 'viewer-error'; - if (!window.docx || typeof window.docx.renderAsync !== 'function') { - throw new Error('Library docx-preview belum dimuat.'); - } + const icon = document.createElement('i'); + icon.className = 'fas fa-file'; - const container = document.createElement('div'); - container.className = 'viewer-docx-content'; - this.contentEl.replaceChildren(container); + const span = document.createElement('span'); + span.textContent = `Pratinjau tidak tersedia untuk format ${this.type.toUpperCase()}. Silakan unduh berkas.`; - await window.docx.renderAsync(blob, container, null, { - inWrapper: true, - ignoreWidth: false, - ignoreHeight: false, - }); - } - - async _loadDocxLibraries() { - if (window.docx) return; - await ScriptLoader.load('https://unpkg.com/jszip@3.10.1/dist/jszip.min.js'); - await ScriptLoader.load('https://unpkg.com/docx-preview@0.3.5/dist/docx-preview.js'); + wrapper.append(icon, span); + this.contentEl.replaceChildren(wrapper); } _renderError(message) { diff --git a/groups/Tapops/js/viewer/script-loader.js b/groups/Tapops/js/viewer/script-loader.js deleted file mode 100644 index 060e089..0000000 --- a/groups/Tapops/js/viewer/script-loader.js +++ /dev/null @@ -1,11 +0,0 @@ -export class ScriptLoader { - static load(src) { - return new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = src; - script.onload = resolve; - script.onerror = () => reject(new Error(`Gagal memuat skrip: ${src}`)); - document.head.appendChild(script); - }); - } -} diff --git a/groups/Tapops/js/viewer/viewer-progress-bar.js b/groups/Tapops/js/viewer/viewer-progress-bar.js deleted file mode 100644 index 5f478b9..0000000 --- a/groups/Tapops/js/viewer/viewer-progress-bar.js +++ /dev/null @@ -1,19 +0,0 @@ -export class ViewerProgressBar { - constructor(contentEl) { - this.bar = document.createElement('div'); - this.bar.className = 'viewer-progress-bar'; - this.fill = document.createElement('div'); - this.fill.className = 'viewer-progress-fill'; - this.bar.appendChild(this.fill); - contentEl.prepend(this.bar); - } - - update(ratio) { - this.fill.style.width = `${Math.min(Math.round(ratio * 100), 100)}%`; - } - - finish() { - this.fill.style.width = '100%'; - setTimeout(() => this.bar.remove(), 300); - } -} 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 index ce54bb6..9933fe8 100644 --- a/groups/Tapops/tests/document-formatter.test.mjs +++ b/groups/Tapops/tests/document-formatter.test.mjs @@ -15,6 +15,14 @@ test('parses comma-separated and JSON string tags', () => { ]); }); +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'); @@ -26,6 +34,78 @@ test('maps parsed tags to fixed repository categories', () => { 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 index c8c21e7..8a61981 100644 --- a/groups/Tapops/tests/static-structure.test.mjs +++ b/groups/Tapops/tests/static-structure.test.mjs @@ -4,6 +4,7 @@ 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, /