fix(tapops): deteksi format file dan format tag

This commit is contained in:
Dodo
2026-06-04 18:53:39 +07:00
parent 36eebac312
commit 5c5c6aa42c
13 changed files with 548 additions and 144 deletions
@@ -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);
}
}