Perbaiki deteksi format file, preview DOCX, dan tampilan tag Tapops #3

Merged
naufalzakyramadhan merged 1 commits from fix/tapops-file-preview-icons into feat/tapops-group 2026-06-04 12:28:25 +00:00
13 changed files with 548 additions and 144 deletions
+24 -1
View File
@@ -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;
}
+3 -55
View File
@@ -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;
@@ -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
? `<div class="doc-tags"><i class="fas fa-tags doc-tags-icon"></i>${this.tags.map(t => `<span class="doc-tag">${DocumentFormatter.escapeHtml(t)}</span>`).join('')}</div>`
? `<div class="doc-tags"><i class="fas fa-tags doc-tags-icon"></i>${this.tags.map(t => `<span class="doc-tag">${DocumentFormatter.escapeHtml(DocumentFormatter.formatTag(t))}</span>`).join('')}</div>`
: '';
card.innerHTML = `
+14 -5
View File
@@ -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,
});
+15 -3
View File
@@ -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';
+137 -4
View File
@@ -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();
+33 -41
View File
@@ -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 = '<i class="fas fa-spinner fa-spin"></i> 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) {
-11
View File
@@ -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);
});
}
}
@@ -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);
}
}
@@ -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');
});
@@ -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',
});
});
@@ -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');
});
@@ -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, /<script type="module" src="js\/app\.js"><\/script>/);
@@ -15,3 +16,7 @@ test('Tapops viewer uses module scripts and no inline behavior', () => {
assert.match(viewerHtml, /<script type="module" src="js\/viewer\/viewer-app\.js"><\/script>/);
assert.doesNotMatch(viewerHtml, /<script>\s*\(/);
});
test('Tapops document tags keep the API-provided casing', () => {
assert.doesNotMatch(repositoryCardsCss, /\.doc-tag\s*\{[^}]*text-transform:\s*lowercase/is);
});