forked from izu/student-web-if-development-kit
fix(tapops): deteksi format file dan format tag
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user