419 lines
13 KiB
Vue
419 lines
13 KiB
Vue
<script setup>
|
||
import { ref, computed, watch, onUnmounted } from "vue";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/Components/ui/dialog";
|
||
import { Button } from "@/Components/ui/button";
|
||
import { Badge } from "../ui/badge";
|
||
import { Loader2, RotateCcwIcon } from "lucide-vue-next";
|
||
import axios from "axios";
|
||
|
||
const props = defineProps({
|
||
show: { type: Boolean, default: false },
|
||
src: { type: String, default: "" },
|
||
title: { type: String, default: "Dokument" },
|
||
mimeType: { type: String, default: "" },
|
||
filename: { type: String, default: "" },
|
||
});
|
||
const emit = defineEmits(["close"]);
|
||
|
||
const textContent = ref("");
|
||
const loading = ref(false);
|
||
const previewGenerating = ref(false);
|
||
const previewError = ref("");
|
||
|
||
// Image viewer – zoom & pan state
|
||
const containerRef = ref(null);
|
||
const imageRef = ref(null);
|
||
const imageScale = ref(1);
|
||
const translateX = ref(0);
|
||
const translateY = ref(0);
|
||
const fitScale = ref(1);
|
||
const isDragging = ref(false);
|
||
const hasMoved = ref(false);
|
||
const dragStartX = ref(0);
|
||
const dragStartY = ref(0);
|
||
const dragStartTX = ref(0);
|
||
const dragStartTY = ref(0);
|
||
|
||
const MAX_SCALE = 8;
|
||
const ZOOM_FACTOR = 2;
|
||
|
||
const imageCursorClass = computed(() => {
|
||
if (isDragging.value && hasMoved.value) return "cursor-grabbing";
|
||
if (imageScale.value > fitScale.value + 0.01) return "cursor-grab";
|
||
return "cursor-zoom-in";
|
||
});
|
||
|
||
const initImageView = () => {
|
||
const container = containerRef.value;
|
||
const img = imageRef.value;
|
||
if (!container || !img) return;
|
||
const cW = container.clientWidth;
|
||
const cH = container.clientHeight;
|
||
const iW = img.naturalWidth || cW;
|
||
const iH = img.naturalHeight || cH;
|
||
const fs = Math.min(cW / iW, cH / iH);
|
||
fitScale.value = fs;
|
||
imageScale.value = fs;
|
||
translateX.value = (cW - iW * fs) / 2;
|
||
translateY.value = (cH - iH * fs) / 2;
|
||
};
|
||
|
||
const resetImageView = () => {
|
||
initImageView();
|
||
};
|
||
|
||
const clampTranslate = (tx, ty, scale) => {
|
||
const container = containerRef.value;
|
||
const img = imageRef.value;
|
||
if (!container || !img) return { tx, ty };
|
||
const cW = container.clientWidth;
|
||
const cH = container.clientHeight;
|
||
const iW = img.naturalWidth * scale;
|
||
const iH = img.naturalHeight * scale;
|
||
// When image fills the container: clamp so image edges stay within container.
|
||
// When image is smaller than container: keep it centered.
|
||
const minX = iW >= cW ? cW - iW : (cW - iW) / 2;
|
||
const maxX = iW >= cW ? 0 : (cW - iW) / 2;
|
||
const minY = iH >= cH ? cH - iH : (cH - iH) / 2;
|
||
const maxY = iH >= cH ? 0 : (cH - iH) / 2;
|
||
return {
|
||
tx: Math.min(maxX, Math.max(minX, tx)),
|
||
ty: Math.min(maxY, Math.max(minY, ty)),
|
||
};
|
||
};
|
||
|
||
const zoomAt = (mx, my, factor) => {
|
||
const img = imageRef.value;
|
||
const iW = img?.naturalWidth ?? 1;
|
||
const iH = img?.naturalHeight ?? 1;
|
||
const raw = imageScale.value * factor;
|
||
const newScale = Math.min(MAX_SCALE, Math.max(fitScale.value, raw));
|
||
if (newScale === imageScale.value) return;
|
||
let tx = mx - ((mx - translateX.value) / imageScale.value) * newScale;
|
||
let ty = my - ((my - translateY.value) / imageScale.value) * newScale;
|
||
const clamped = clampTranslate(tx, ty, newScale);
|
||
translateX.value = clamped.tx;
|
||
translateY.value = clamped.ty;
|
||
imageScale.value = newScale;
|
||
};
|
||
|
||
const mousePos = (e) => {
|
||
const rect = containerRef.value.getBoundingClientRect();
|
||
return { mx: e.clientX - rect.left, my: e.clientY - rect.top };
|
||
};
|
||
|
||
const handleImageLoad = () => {
|
||
initImageView();
|
||
};
|
||
|
||
const handleClick = (e) => {
|
||
if (hasMoved.value) return;
|
||
const { mx, my } = mousePos(e);
|
||
zoomAt(mx, my, ZOOM_FACTOR);
|
||
};
|
||
|
||
const handleContextMenu = (e) => {
|
||
e.preventDefault();
|
||
if (hasMoved.value) return;
|
||
if (imageScale.value <= fitScale.value + 0.01) return;
|
||
const { mx, my } = mousePos(e);
|
||
zoomAt(mx, my, 1 / ZOOM_FACTOR);
|
||
};
|
||
|
||
const handleWheel = (e) => {
|
||
e.preventDefault();
|
||
const { mx, my } = mousePos(e);
|
||
zoomAt(mx, my, e.deltaY < 0 ? 1.2 : 1 / 1.2);
|
||
};
|
||
|
||
const onMouseMove = (e) => {
|
||
if (!isDragging.value) return;
|
||
const dx = e.clientX - dragStartX.value;
|
||
const dy = e.clientY - dragStartY.value;
|
||
if (!hasMoved.value && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) {
|
||
hasMoved.value = true;
|
||
}
|
||
if (hasMoved.value) {
|
||
const clamped = clampTranslate(
|
||
dragStartTX.value + dx,
|
||
dragStartTY.value + dy,
|
||
imageScale.value
|
||
);
|
||
translateX.value = clamped.tx;
|
||
translateY.value = clamped.ty;
|
||
}
|
||
};
|
||
|
||
const onMouseUp = () => {
|
||
isDragging.value = false;
|
||
// Delay reset so the click/contextmenu handler that fires after mouseup can still read hasMoved
|
||
setTimeout(() => {
|
||
hasMoved.value = false;
|
||
}, 0);
|
||
window.removeEventListener("mousemove", onMouseMove);
|
||
window.removeEventListener("mouseup", onMouseUp);
|
||
};
|
||
|
||
const handleMouseDown = (e) => {
|
||
if (e.button !== 0) return;
|
||
isDragging.value = true;
|
||
hasMoved.value = false;
|
||
dragStartX.value = e.clientX;
|
||
dragStartY.value = e.clientY;
|
||
dragStartTX.value = translateX.value;
|
||
dragStartTY.value = translateY.value;
|
||
window.addEventListener("mousemove", onMouseMove);
|
||
window.addEventListener("mouseup", onMouseUp);
|
||
};
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener("mousemove", onMouseMove);
|
||
window.removeEventListener("mouseup", onMouseUp);
|
||
});
|
||
|
||
const fileExtension = computed(() => {
|
||
if (props.filename) {
|
||
return props.filename.split(".").pop()?.toLowerCase() || "";
|
||
}
|
||
return "";
|
||
});
|
||
|
||
const viewerType = computed(() => {
|
||
const ext = fileExtension.value;
|
||
const mime = props.mimeType.toLowerCase();
|
||
|
||
if (ext === "pdf" || mime === "application/pdf") return "pdf";
|
||
// DOCX/DOC files are converted to PDF by backend - treat as PDF viewer
|
||
if (["doc", "docx"].includes(ext) || mime.includes("word") || mime.includes("msword"))
|
||
return "docx";
|
||
if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext) || mime.startsWith("image/"))
|
||
return "image";
|
||
if (["txt", "csv", "xml"].includes(ext) || mime.startsWith("text/")) return "text";
|
||
|
||
return "unsupported";
|
||
});
|
||
|
||
const loadTextContent = async () => {
|
||
if (!props.src || viewerType.value !== "text") return;
|
||
loading.value = true;
|
||
try {
|
||
const response = await axios.get(props.src);
|
||
textContent.value = response.data;
|
||
} catch (e) {
|
||
textContent.value = "Napaka pri nalaganju vsebine.";
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
// For DOCX files, the backend converts to PDF. If the preview isn't ready yet (202 status),
|
||
// we poll until it's available.
|
||
const docxPreviewUrl = ref("");
|
||
const loadDocxPreview = async () => {
|
||
if (!props.src || viewerType.value !== "docx") return;
|
||
|
||
previewGenerating.value = true;
|
||
previewError.value = "";
|
||
docxPreviewUrl.value = "";
|
||
|
||
const maxRetries = 15;
|
||
const retryDelay = 2000; // 2 seconds between retries
|
||
|
||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||
try {
|
||
const response = await axios.head(props.src, { validateStatus: () => true });
|
||
|
||
if (response.status >= 200 && response.status < 300) {
|
||
// Preview is ready
|
||
docxPreviewUrl.value = props.src;
|
||
previewGenerating.value = false;
|
||
return;
|
||
} else if (response.status === 202) {
|
||
// Preview is being generated, wait and retry
|
||
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||
} else {
|
||
// Other error
|
||
previewError.value = "Napaka pri nalaganju predogleda.";
|
||
previewGenerating.value = false;
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
previewError.value = "Napaka pri nalaganju predogleda.";
|
||
previewGenerating.value = false;
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Max retries reached
|
||
previewError.value = "Predogled ni na voljo. Prosimo poskusite znova kasneje.";
|
||
previewGenerating.value = false;
|
||
};
|
||
|
||
watch(
|
||
() => [props.show, props.src],
|
||
([show]) => {
|
||
if (show && viewerType.value === "text") {
|
||
loadTextContent();
|
||
}
|
||
if (show && viewerType.value === "docx") {
|
||
loadDocxPreview();
|
||
}
|
||
// Reset states when dialog closes
|
||
if (!show) {
|
||
previewGenerating.value = false;
|
||
previewError.value = "";
|
||
docxPreviewUrl.value = "";
|
||
imageScale.value = 1;
|
||
translateX.value = 0;
|
||
translateY.value = 0;
|
||
fitScale.value = 1;
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
</script>
|
||
|
||
<template>
|
||
<Dialog :open="show" @update:open="(open) => !open && $emit('close')">
|
||
<DialogContent class="max-w-full xl:max-w-7xl">
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
{{ title }}
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
<Badge>
|
||
{{ fileExtension }}
|
||
</Badge>
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div class="h-[70vh] overflow-auto">
|
||
<!-- PDF Viewer (browser native) -->
|
||
<template v-if="viewerType === 'pdf' && props.src">
|
||
<iframe
|
||
:src="props.src"
|
||
class="w-full h-full rounded border"
|
||
type="application/pdf"
|
||
/>
|
||
</template>
|
||
|
||
<!-- DOCX Viewer (converted to PDF by backend) -->
|
||
<template v-else-if="viewerType === 'docx'">
|
||
<!-- Loading/generating state -->
|
||
<div
|
||
v-if="previewGenerating"
|
||
class="flex flex-col items-center justify-center h-full gap-4"
|
||
>
|
||
<Loader2 class="h-8 w-8 animate-spin text-indigo-600" />
|
||
<span class="text-gray-500">Priprava predogleda dokumenta...</span>
|
||
</div>
|
||
<!-- Error state -->
|
||
<div
|
||
v-else-if="previewError"
|
||
class="flex flex-col items-center justify-center h-full gap-4 text-gray-500"
|
||
>
|
||
<span>{{ previewError }}</span>
|
||
<Button as="a" :href="props.src" target="_blank" variant="outline">
|
||
Prenesi datoteko
|
||
</Button>
|
||
</div>
|
||
<!-- Preview ready -->
|
||
<iframe
|
||
v-else-if="docxPreviewUrl"
|
||
:src="docxPreviewUrl"
|
||
class="w-full h-full rounded border"
|
||
type="application/pdf"
|
||
/>
|
||
</template>
|
||
|
||
<!-- Image Viewer -->
|
||
<template v-else-if="viewerType === 'image' && props.src">
|
||
<div
|
||
ref="containerRef"
|
||
class="relative h-full overflow-hidden select-none"
|
||
:class="imageCursorClass"
|
||
@click="handleClick"
|
||
@contextmenu="handleContextMenu"
|
||
@mousedown="handleMouseDown"
|
||
@wheel.prevent="handleWheel"
|
||
>
|
||
<img
|
||
ref="imageRef"
|
||
:src="props.src"
|
||
:alt="props.title"
|
||
class="absolute top-0 left-0 max-w-none pointer-events-none"
|
||
:style="{
|
||
transformOrigin: '0 0',
|
||
transform: `translate(${translateX}px, ${translateY}px) scale(${imageScale})`,
|
||
transition: isDragging ? 'none' : 'transform 0.12s ease',
|
||
}"
|
||
@load="handleImageLoad"
|
||
/>
|
||
<!-- Zoom level badge -->
|
||
<div
|
||
class="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded pointer-events-none"
|
||
>
|
||
{{ Math.round(imageScale * 100) }}%
|
||
</div>
|
||
<!-- Reset button -->
|
||
<Button
|
||
v-if="imageScale > fitScale + 0.01"
|
||
size="icon-sm"
|
||
variant="secondary"
|
||
class="absolute top-2 right-2 opacity-70 hover:opacity-100"
|
||
title="Ponastavi pogled"
|
||
@click.stop="resetImageView"
|
||
>
|
||
<RotateCcwIcon class="h-3 w-3" />
|
||
</Button>
|
||
<!-- Hint -->
|
||
<div
|
||
v-if="imageScale <= fitScale + 0.01"
|
||
class="absolute bottom-2 left-1/2 -translate-x-1/2 bg-black/40 text-white text-xs px-2 py-1 rounded pointer-events-none select-none"
|
||
>
|
||
Klik za povečavo · Desni klik / kolesce za pomanjšavo · Povleči za premik
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Text/CSV/XML Viewer -->
|
||
<template v-else-if="viewerType === 'text'">
|
||
<div v-if="loading" class="flex items-center justify-center h-full">
|
||
<div class="animate-pulse text-gray-500">Nalaganje...</div>
|
||
</div>
|
||
<pre
|
||
v-else
|
||
class="p-4 bg-gray-50 dark:bg-gray-900 rounded border text-sm overflow-auto h-full whitespace-pre-wrap wrap-break-word"
|
||
>{{ textContent }}</pre
|
||
>
|
||
</template>
|
||
|
||
<!-- Unsupported -->
|
||
<template v-else-if="viewerType === 'unsupported'">
|
||
<div
|
||
class="flex flex-col items-center justify-center h-full gap-4 text-gray-500"
|
||
>
|
||
<span>Predogled ni na voljo za to vrsto datoteke.</span>
|
||
<Button as="a" :href="props.src" target="_blank" variant="outline">
|
||
Prenesi datoteko
|
||
</Button>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- No source -->
|
||
<div v-else class="text-sm text-gray-500">Ni dokumenta za prikaz.</div>
|
||
</div>
|
||
|
||
<div class="flex justify-end mt-4">
|
||
<Button type="button" variant="outline" @click="$emit('close')">Zapri</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</template>
|