Files
thetool/public/plugins/vue/tt-components/tt-file-gallery.js
2025-12-03 14:08:44 +00:00

361 lines
15 KiB
JavaScript

Vue.component('tt-fullscreen-viewer', {
props: {
item: { type: Object, required: true },
url: { type: String, default: null },
items: { type: Array, default: () => [] },
initialIndex: { type: Number, default: -1 },
},
data: () => ({
currentItem: null,
currentImageIndex: -1,
zoom: 1,
pan: { x: 0, y: 0 },
isPanning: false,
panStart: { x: 0, y: 0 },
lastPinchDist: 0,
isLoading: true,
isStandalone: false,
}),
computed: {
contentSrc() {
if (this.url) {
return this.url;
}
if (this.currentItem?.fileId) {
return `/File/show?id=${this.currentItem.fileId}`;
}
return null;
},
isViewingImage() {
return this.currentItem && this.isImage(this.currentItem);
},
imageTransformStyle() {
return {
transform: `translate(${this.pan.x}px, ${this.pan.y}px) scale(${this.zoom})`,
cursor: this.isPanning ? 'grabbing' : 'grab',
transition: this.isPanning ? 'none' : 'transform 0.2s',
};
},
downloadUrl() {
return this.currentItem?.fileId ? `/File/download?id=${this.currentItem.fileId}` : '#';
}
},
watch: {
currentItem(newItem) {
if (this.isPdf(newItem) && this.isStandalone) {
this.$nextTick(() => {
this.renderPdfWithJs(this.contentSrc);
});
}
}
},
methods: {
isImage: file => file?.mimetype?.startsWith('image/') || file?.mimetype === 'application/octet-stream',
isPdf: file => file?.mimetype === 'application/pdf',
onContentLoad() {
this.isLoading = false;
},
closeViewer() {
this.$emit('close');
},
navigateImage(direction) {
const newIndex = this.currentImageIndex + direction;
if (newIndex >= 0 && newIndex < this.items.length) {
this.isLoading = true;
this.currentImageIndex = newIndex;
this.currentItem = this.items[newIndex];
this.resetZoomAndPan();
}
},
handleKeyDown(e) {
e.stopPropagation();
if (!this.currentItem) return;
switch (e.key) {
case 'Escape': this.closeViewer(); break;
case 'ArrowLeft': this.isViewingImage && this.navigateImage(-1); break;
case 'ArrowRight': this.isViewingImage && this.navigateImage(1); break;
}
},
resetZoomAndPan() {
this.zoom = 1;
this.pan = { x: 0, y: 0 };
this.isPanning = false;
},
handleWheel(e) {
if (!this.isViewingImage) return;
e.preventDefault();
const scaleFactor = e.deltaY > 0 ? -0.2 : 0.2;
this.zoom = Math.max(1, Math.min(this.zoom + scaleFactor, 5));
},
onPanStart(e) {
if (this.zoom <= 1) return;
e.preventDefault();
this.isPanning = true;
this.panStart.x = e.clientX - this.pan.x;
this.panStart.y = e.clientY - this.pan.y;
},
onPanMove(e) {
if (!this.isPanning) return;
this.pan.x = e.clientX - this.panStart.x;
this.pan.y = e.clientY - this.panStart.y;
},
onPanEnd() {
this.isPanning = false;
},
onTouchStart(e) {},
onTouchMove(e) {},
onTouchEnd(e) {},
loadPdfJsScript() {
return new Promise((resolve, reject) => {
if (document.getElementById('pdfjs-script')) {
if (window.pdfjsLib) {
resolve();
} else {
document.getElementById('pdfjs-script').addEventListener('load', () => resolve());
document.getElementById('pdfjs-script').addEventListener('error', (e) => reject(e));
}
return;
}
const script = document.createElement('script');
script.id = 'pdfjs-script';
script.type = 'module';
script.src = 'https://unpkg.com/pdfjs-dist@5.4.54/build/pdf.mjs'; // CDN URL
script.onload = () => resolve();
script.onerror = (err) => {
console.error("Failed to load PDF.js script.", err);
reject(new Error("PDF.js script could not be loaded."));
};
document.head.appendChild(script);
});
},
async renderPdfWithJs(url) {
if (!url) return;
this.isLoading = true;
try {
await this.loadPdfJsScript();
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://unpkg.com/pdfjs-dist@5.4.54/build/pdf.worker.min.mjs';
const pdf = await pdfjsLib.getDocument(url).promise;
const page = await pdf.getPage(1);
const canvas = this.$refs.pdfCanvas;
if (!canvas) return;
const container = canvas.parentElement;
if (!container) return;
const context = canvas.getContext('2d');
const unscaledViewport = page.getViewport({ scale: 1 });
const scale = Math.min(
container.clientWidth / unscaledViewport.width,
container.clientHeight / unscaledViewport.height
);
const viewport = page.getViewport({ scale: scale });
canvas.height = viewport.height;
canvas.width = viewport.width;
await page.render({ canvasContext: context, viewport: viewport }).promise;
} catch (error) {
console.error('Error rendering PDF with PDF.js:', error);
} finally {
this.onContentLoad();
}
},
},
created() {
this.currentItem = this.item;
this.currentImageIndex = this.initialIndex;
if (typeof window !== 'undefined' && window.matchMedia) {
this.isStandalone = window.matchMedia('(display-mode: standalone)').matches;
}
},
mounted() {
document.body.style.overflow = 'hidden';
this.$nextTick(() => {
this.$refs.viewer?.focus();
if (this.isPdf(this.currentItem) && this.isStandalone) {
this.renderPdfWithJs(this.contentSrc);
}
});
},
beforeDestroy() {
document.body.style.overflow = '';
},
template: `
<div class="tt-fullscreen-overlay" @click.self="closeViewer" @keydown="handleKeyDown" tabindex="-1" ref="viewer">
<div class="tt-fullscreen-toolbar">
<a v-if="currentItem.fileId" :href="downloadUrl" download class="tt-fullscreen-btn" title="Download">
<i class="fas fa-download"></i>
</a>
<button class="tt-fullscreen-btn" @click="closeViewer" title="Close"><i class="fas fa-times"></i></button>
</div>
<div class="tt-fullscreen-content" @click.self="closeViewer">
<div v-if="isLoading" class="tt-fullscreen-loader">
<svg version="1.1" id="L9" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 100 100" enable-background="new 0 0 0 0" xml:space="preserve" style="width: 80px; height: 80px;">
<path fill="#fff" d="M73,50c0-12.7-10.3-23-23-23S27,37.3,27,50 M30.9,50c0-10.5,8.5-19.1,19.1-19.1S69.1,39.5,69.1,50"></path>
</svg>
</div>
<template v-if="isViewingImage">
<div class="tt-fullscreen-image-wrapper" @wheel="handleWheel" @mousedown="onPanStart" @mousemove="onPanMove"
@mouseup="onPanEnd" @mouseleave="onPanEnd">
<img v-show="!isLoading" :src="contentSrc" class="tt-fullscreen-image" :style="imageTransformStyle"
@load="onContentLoad" @error="onContentLoad" @click.stop/>
</div>
</template>
<div v-else-if="isPdf(currentItem) && isStandalone" v-show="!isLoading" class="tt-fullscreen-pdf-standalone-container">
<canvas ref="pdfCanvas" class="tt-fullscreen-pdf-standalone"></canvas>
</div>
<iframe v-else-if="isPdf(currentItem)" v-show="!isLoading" :src="contentSrc" class="tt-fullscreen-pdf"
@load="onContentLoad" @error="onContentLoad" @click.stop></iframe>
</div>
<template v-if="isViewingImage && items.length > 1">
<button class="tt-fullscreen-nav-btn left" @click.stop="navigateImage(-1)" v-if="currentImageIndex > 0">
<i class="fas fa-chevron-left"></i>
</button>
<button class="tt-fullscreen-nav-btn right" @click.stop="navigateImage(1)" v-if="currentImageIndex < items.length - 1">
<i class="fas fa-chevron-right"></i>
</button>
</template>
</div>`,
})
Vue.component('tt-file-gallery', {
props: {
files: {type: Array, default: () => []},
editMode: {type: Boolean, default: false},
deleteMode: {type: Boolean, default: false},
selectable: {type: Boolean, default: false},
},
data: () => ({
fullscreenItem: null,
editingFile: null,
selectedFiles: [],
missingFileIds: new Set(),
}),
computed: {
imageFiles() {
return this.files.filter(this.isImage);
},
},
methods: {
isImage: file => file.mimetype?.startsWith('image/') || file.mimetype === 'application/octet-stream',
isPdf: file => file.mimetype === 'application/pdf',
getFileIcon(file) {
const extension = file.fileName?.split('.').pop().toLowerCase();
switch (extension) {
case 'doc':
case 'docx':
return 'fas fa-file-word text-primary';
case 'xls':
case 'xlsx':
return 'fas fa-file-excel text-success';
case 'zip':
case 'rar':
return 'fas fa-file-archive text-warning';
default:
return 'fas fa-file text-secondary';
}
},
toggleSelection(fileId) {
if (!this.selectable) return;
const index = this.selectedFiles.indexOf(fileId);
if (index > -1) this.selectedFiles.splice(index, 1);
else this.selectedFiles.push(fileId);
this.$emit('selection-changed', this.selectedFiles);
},
handleImageError(file) {
if (!file || !file.id) return;
this.missingFileIds.add(file.id);
this.$forceUpdate(); // Force a re-render as Vue might not detect the Set change
},
isMissing(file) {
return this.missingFileIds.has(file.id);
},
openViewer(file) {
if (this.isMissing(file) || this.editingFile) return;
this.fullscreenItem = file;
},
closeViewer() {
this.fullscreenItem = null;
},
startEdit(file, event) {
event?.stopPropagation();
this.editingFile = {...file};
},
cancelEdit() {
this.editingFile = null;
},
saveEdit(event) {
event?.stopPropagation();
this.$emit('update-file', this.editingFile);
this.editingFile = null;
},
deleteFile(file, event) {
event?.stopPropagation();
if (confirm(`Sind Sie sicher, dass Sie die Datei "${file.fileName}" löschen möchten?`)) {
this.$emit('delete-file', file);
}
},
},
template: `
<div class="card">
<div class="card-header"><i class="fas fa-images mr-2"></i><h5>Hochgeladene Dokumente</h5></div>
<div v-if="!files.length" class="card-body text-center text-muted">Keine Dokumente vorhanden.</div>
<div v-else class="card-body">
<div class="tt-file-gallery-grid">
<div v-for="file in files" :key="file.id" class="tt-file-gallery-item"
:class="[{ 'selected': selectable && selectedFiles.includes(file.id), 'is-missing': isMissing(file) }, file.class]"
@click="openViewer(file)">
<div v-if="selectable" class="selection-indicator" @click.stop="toggleSelection(file.id)">
<i :class="selectedFiles.includes(file.id) ? 'fas fa-check-circle text-primary' : 'far fa-circle text-muted'"></i>
</div>
<div class="tt-file-gallery-thumbnail-wrapper">
<div v-if="isMissing(file)" class="tt-file-gallery-icon-container">
<i class="fas fa-exclamation-triangle fa-3x text-warning" title="File not found"></i>
</div>
<img v-else-if="isImage(file)" :src="'/File/show?id=' + file.fileId + '&size=small'"
class="tt-file-gallery-thumbnail" :alt="file.fileName" @error="handleImageError(file)">
<div v-else-if="isPdf(file)" class="tt-file-gallery-icon-container">
<i class="fas fa-file-pdf fa-3x text-danger"></i>
</div>
<a v-else :href="'/File/download?id=' + file.fileId" target="_blank" @click.stop
class="tt-file-gallery-icon-container">
<i :class="getFileIcon(file)" class="fa-3x"></i>
</a>
<div class="tt-file-gallery-overlay" @click.stop="openViewer(file)">
<i class="fas fa-search-plus"></i>
</div>
</div>
<div class="tt-file-gallery-filename" :title="file.fileName">
<span style="overflow: hidden; text-overflow: ellipsis;">{{ file.fileName }}</span>
<div>
<i v-if="editMode" class="fas fa-edit text-primary ml-1 action-icon" @click="startEdit(file, $event)"></i>
<i v-if="deleteMode" class="fas fa-trash text-danger ml-1 action-icon" @click="deleteFile(file, $event)"></i>
</div>
</div>
<div v-if="file.description" class="tt-file-gallery-description" :title="file.description">{{ file.description }}</div>
</div>
</div>
</div>
<tt-modal v-if="editingFile" :show="true" title="Dokument bearbeiten" @update:show="cancelEdit"
@submit="saveEdit" :delete="false" :disable-min-height="true">
<slot name="file-edit" :file="editingFile"></slot>
</tt-modal>
<tt-fullscreen-viewer v-if="fullscreenItem" :item="fullscreenItem" :items="imageFiles"
:initial-index="imageFiles.findIndex(f => f.id === fullscreenItem.id)"
@close="closeViewer"/>
</div>`
});