Phone view case updated

This commit is contained in:
Simon Pocrnjič
2026-06-21 19:49:04 +02:00
parent ea9376c713
commit f8f019408a
14 changed files with 1557 additions and 400 deletions
@@ -0,0 +1,427 @@
<script setup>
import { ref, computed, watch, onUnmounted } from "vue";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerFooter,
DrawerClose,
} from "@/Components/ui/drawer";
import { ScrollArea } from "@/Components/ui/scroll-area";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/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 imageCursorClass = computed(() => {
if (isDragging.value && hasMoved.value) return "cursor-grabbing";
if (imageScale.value > fitScale.value + 0.01) return "cursor-grab";
return "cursor-default";
});
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(1, 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;
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 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 handleWheel = (e) => {
e.preventDefault();
const { mx, my } = mousePos(e);
zoomAt(mx, my, e.deltaY < 0 ? 1.2 : 1 / 1.2);
};
// Touch pinch-to-zoom
let lastTouchDist = null;
let lastTouchMidX = null;
let lastTouchMidY = null;
const getTouchDist = (touches) => {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.hypot(dx, dy);
};
const handleTouchStart = (e) => {
if (e.touches.length === 2) {
lastTouchDist = getTouchDist(e.touches);
const rect = containerRef.value.getBoundingClientRect();
lastTouchMidX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
lastTouchMidY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
e.preventDefault();
} else if (e.touches.length === 1) {
isDragging.value = true;
hasMoved.value = false;
dragStartX.value = e.touches[0].clientX;
dragStartY.value = e.touches[0].clientY;
dragStartTX.value = translateX.value;
dragStartTY.value = translateY.value;
}
};
const handleTouchMove = (e) => {
if (e.touches.length === 2 && lastTouchDist !== null) {
e.preventDefault();
const dist = getTouchDist(e.touches);
const factor = dist / lastTouchDist;
const rect = containerRef.value.getBoundingClientRect();
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
zoomAt(midX, midY, factor);
lastTouchDist = dist;
lastTouchMidX = midX;
lastTouchMidY = midY;
} else if (e.touches.length === 1 && isDragging.value) {
const dx = e.touches[0].clientX - dragStartX.value;
const dy = e.touches[0].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 handleTouchEnd = () => {
lastTouchDist = null;
isDragging.value = false;
setTimeout(() => { hasMoved.value = false; }, 0);
};
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;
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";
if (["doc", "docx"].includes(ext) || mime.includes("word") || mime.includes("msword"))
return "docx";
if (["jpg", "jpeg", "png", "gif", "webp", "heic", "heif"].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 {
textContent.value = "Napaka pri nalaganju vsebine.";
} finally {
loading.value = false;
}
};
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;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await axios.head(props.src, { validateStatus: () => true });
if (response.status >= 200 && response.status < 300) {
docxPreviewUrl.value = props.src;
previewGenerating.value = false;
return;
} else if (response.status === 202) {
await new Promise((resolve) => setTimeout(resolve, retryDelay));
} else {
previewError.value = "Napaka pri nalaganju predogleda.";
previewGenerating.value = false;
return;
}
} catch {
previewError.value = "Napaka pri nalaganju predogleda.";
previewGenerating.value = false;
return;
}
}
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();
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>
<Drawer :open="show" @update:open="(val) => !val && emit('close')">
<DrawerContent class="flex flex-col h-[95vh]">
<DrawerHeader class="border-b px-4 py-3 shrink-0">
<DrawerTitle class="truncate pr-4">{{ title }}</DrawerTitle>
<div class="mt-1">
<Badge>{{ fileExtension }}</Badge>
</div>
</DrawerHeader>
<!-- Viewer area: flex-1 + min-h-0 works because parent has fixed h-[95vh] -->
<div class="flex-1 min-h-0 overflow-hidden">
<!-- PDF Viewer -->
<template v-if="viewerType === 'pdf' && src">
<iframe :src="src" class="w-full h-full" type="application/pdf" />
</template>
<!-- DOCX Viewer (converted to PDF by backend) -->
<template v-else-if="viewerType === 'docx'">
<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>
<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="src" target="_blank" variant="outline">
Prenesi datoteko
</Button>
</div>
<iframe
v-else-if="docxPreviewUrl"
:src="docxPreviewUrl"
class="w-full h-full"
type="application/pdf"
/>
</template>
<!-- Image Viewer with touch pinch-to-zoom -->
<template v-else-if="viewerType === 'image' && src">
<div
ref="containerRef"
class="relative h-full overflow-hidden select-none"
:class="imageCursorClass"
@mousedown="handleMouseDown"
@wheel.prevent="handleWheel"
@touchstart.prevent="handleTouchStart"
@touchmove.prevent="handleTouchMove"
@touchend="handleTouchEnd"
>
<img
ref="imageRef"
:src="src"
:alt="title"
draggable="false"
class="absolute top-0 left-0 max-w-none"
:style="{
transformOrigin: '0 0',
transform: `translate(${translateX}px, ${translateY}px) scale(${imageScale})`,
transition: isDragging ? 'none' : 'transform 0.12s ease',
}"
@load="handleImageLoad"
/>
<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>
<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>
<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"
>
Ščipni za poveč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>
<ScrollArea v-else class="h-full">
<pre
class="p-4 bg-gray-50 dark:bg-gray-900 text-sm whitespace-pre-wrap wrap-break-word"
>{{ textContent }}</pre
>
</ScrollArea>
</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="src" target="_blank" variant="outline">
Prenesi datoteko
</Button>
</div>
</template>
<div v-else class="flex items-center justify-center h-full text-sm text-gray-500">
Ni dokumenta za prikaz.
</div>
</div>
<DrawerFooter class="border-t shrink-0 px-4 py-3">
<DrawerClose as-child>
<Button variant="outline" class="w-full" @click="emit('close')">Zapri</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</template>