forked from izu/student-web-if-development-kit
feat(tapops): add css modules, js scripts, and viewer page
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Viewer page — renders a single Directus asset in-browser.
|
||||
* PDF → native <iframe>
|
||||
* DOCX → docx-preview library (renders document XML to HTML in-page)
|
||||
*
|
||||
* URL params: ?id={uuid}&name={filename}&type={pdf|docx}
|
||||
*/
|
||||
|
||||
class ViewerApi {
|
||||
static API_BASE = 'https://api.ifuntanhub.dev';
|
||||
|
||||
static assetUrl(uuid) {
|
||||
return `${this.API_BASE}/assets/${uuid}`;
|
||||
}
|
||||
|
||||
static async fetchBlob(url, onProgress) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Gagal mengambil berkas (HTTP ${response.status}).`);
|
||||
|
||||
// If the response supports streaming and we have a progress callback,
|
||||
// pipe through a progress-tracking reader.
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
if (onProgress && contentLength && response.body) {
|
||||
const total = parseInt(contentLength, 10);
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let received = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
onProgress(received / total);
|
||||
}
|
||||
return new Blob(chunks);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
}
|
||||
|
||||
class FileViewer {
|
||||
constructor() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
this.id = params.get('id');
|
||||
this.name = params.get('name') || 'Dokumen';
|
||||
this.type = (params.get('type') || 'pdf').toLowerCase();
|
||||
|
||||
this.titleEl = document.getElementById('viewer-title');
|
||||
this.typeBadgeEl = document.getElementById('viewer-type');
|
||||
this.downloadEl = document.getElementById('viewer-download');
|
||||
this.contentEl = document.getElementById('viewer-content');
|
||||
|
||||
this.assetUrl = this.id ? ViewerApi.assetUrl(this.id) : null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.id) {
|
||||
this._renderError('Parameter berkas tidak ditemukan di URL.');
|
||||
return;
|
||||
}
|
||||
|
||||
document.title = `${this.name} — Pratinjau Dokumen`;
|
||||
this.titleEl.textContent = this.name;
|
||||
this.typeBadgeEl.textContent = this.type.toUpperCase();
|
||||
this._setupDownload();
|
||||
|
||||
try {
|
||||
if (this.type === 'docx') {
|
||||
await this._renderDocx();
|
||||
} else {
|
||||
this._renderPdf();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
this._renderError('Gagal memuat pratinjau berkas. Silakan unduh berkas untuk membuka secara lokal.');
|
||||
}
|
||||
}
|
||||
|
||||
_setupDownload() {
|
||||
const ext = `.${this.type}`;
|
||||
const filename = this.name.toLowerCase().endsWith(ext) ? this.name : `${this.name}${ext}`;
|
||||
|
||||
// Use blob-based download so the browser always triggers a save
|
||||
// dialog rather than navigating to the file (cross-origin `download`
|
||||
// attribute is ignored by browsers).
|
||||
this.downloadEl.removeAttribute('href');
|
||||
this.downloadEl.style.cursor = 'pointer';
|
||||
this.downloadEl.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
// Visual feedback while downloading.
|
||||
const origHtml = this.downloadEl.innerHTML;
|
||||
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 a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = blobUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch (err) {
|
||||
console.warn('Blob download failed, falling back:', err);
|
||||
// Fallback: open asset URL with ?download param
|
||||
window.open(`${this.assetUrl}?download`, '_blank');
|
||||
} finally {
|
||||
this.downloadEl.innerHTML = origHtml;
|
||||
this.downloadEl.style.pointerEvents = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_renderPdf() {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.className = 'viewer-pdf-frame';
|
||||
iframe.title = this.name;
|
||||
iframe.src = this.assetUrl;
|
||||
this.contentEl.replaceChildren(iframe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-load a script by URL. Returns a promise that resolves on load.
|
||||
*/
|
||||
static _loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.onload = resolve;
|
||||
s.onerror = () => reject(new Error(`Gagal memuat skrip: ${src}`));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
async _renderDocx() {
|
||||
// Show progress bar immediately.
|
||||
const progressBar = this._showProgress();
|
||||
|
||||
// Load DOCX libraries and fetch the blob in parallel.
|
||||
const [blob] = await Promise.all([
|
||||
ViewerApi.fetchBlob(this.assetUrl, (ratio) => {
|
||||
progressBar.update(ratio);
|
||||
}),
|
||||
this._loadDocxLibraries(),
|
||||
]);
|
||||
progressBar.finish();
|
||||
|
||||
if (typeof docx === 'undefined' || typeof docx.renderAsync !== 'function') {
|
||||
throw new Error('Library docx-preview belum dimuat.');
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'viewer-docx-content';
|
||||
this.contentEl.replaceChildren(container);
|
||||
|
||||
await docx.renderAsync(blob, container, null, {
|
||||
inWrapper: true,
|
||||
ignoreWidth: false,
|
||||
ignoreHeight: false,
|
||||
});
|
||||
}
|
||||
|
||||
async _loadDocxLibraries() {
|
||||
if (typeof docx !== 'undefined') return; // already loaded
|
||||
await FileViewer._loadScript('https://unpkg.com/jszip@3.10.1/dist/jszip.min.js');
|
||||
await FileViewer._loadScript('https://unpkg.com/docx-preview@0.3.5/dist/docx-preview.js');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a thin progress bar at the top of the content area.
|
||||
* Returns { update(ratio), finish() }.
|
||||
*/
|
||||
_showProgress() {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'viewer-progress-bar';
|
||||
const fill = document.createElement('div');
|
||||
fill.className = 'viewer-progress-fill';
|
||||
bar.appendChild(fill);
|
||||
|
||||
// Insert above the loading spinner.
|
||||
this.contentEl.prepend(bar);
|
||||
|
||||
return {
|
||||
update(ratio) {
|
||||
fill.style.width = `${Math.min(Math.round(ratio * 100), 100)}%`;
|
||||
},
|
||||
finish() {
|
||||
fill.style.width = '100%';
|
||||
setTimeout(() => bar.remove(), 300);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
_renderError(message) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'viewer-error';
|
||||
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'fas fa-exclamation-triangle';
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.textContent = message;
|
||||
|
||||
const back = document.createElement('a');
|
||||
back.href = 'index.html#repository';
|
||||
back.className = 'btn-back-mini';
|
||||
back.textContent = 'Kembali ke Repositori';
|
||||
|
||||
wrapper.append(icon, span, back);
|
||||
this.contentEl.replaceChildren(wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new FileViewer().init();
|
||||
});
|
||||
Reference in New Issue
Block a user