fix(tapops): deteksi format file dan format tag
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = `
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user