port-nimara-client-portal/components/FilePreviewModal.vue

323 lines
7.9 KiB
Vue

<template>
<v-dialog
v-model="isOpen"
max-width="90vw"
max-height="90vh"
scrollable
>
<v-card v-if="file">
<v-card-title class="d-flex align-center justify-space-between">
<div class="d-flex align-center">
<v-icon class="mr-2">{{ file.icon }}</v-icon>
<span>{{ file.displayName }}</span>
</div>
<v-btn
icon
variant="text"
@click="closeModal"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-0">
<!-- Loading State -->
<div v-if="loading" class="text-center pa-10">
<v-progress-circular
indeterminate
color="primary"
size="64"
/>
<p class="mt-4">Loading preview...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center pa-10">
<v-icon size="64" color="error">mdi-alert-circle</v-icon>
<p class="mt-4">{{ error }}</p>
</div>
<!-- Image Preview -->
<div v-else-if="isImage" class="image-preview-container">
<img
:src="previewUrl"
:alt="file.displayName"
class="image-preview"
@load="loading = false"
@error="handlePreviewError"
/>
</div>
<!-- PDF Preview -->
<div v-else-if="isPdf" class="pdf-preview-container">
<iframe
:src="previewUrl"
width="100%"
height="100%"
frameborder="0"
class="pdf-iframe"
/>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="primary"
variant="outlined"
@click="downloadFile"
prepend-icon="mdi-download"
>
Download
</v-btn>
<v-btn
color="primary"
variant="flat"
@click="closeModal"
>
Close
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue';
interface FileItem {
name: string;
size: number;
sizeFormatted: string;
lastModified: string;
extension: string;
icon: string;
displayName: string;
isFolder: boolean;
bucket?: string;
}
interface Props {
modelValue: boolean;
file: FileItem | null;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const loading = ref(false);
const error = ref('');
const previewUrl = ref('');
const objectUrl = ref('');
// Computed property for v-model binding
const isOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
});
// Check if file is an image
const isImage = computed(() => {
if (!props.file) return false;
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'];
return imageExtensions.includes(props.file.extension.toLowerCase());
});
// Check if file is a PDF
const isPdf = computed(() => {
if (!props.file) return false;
return props.file.extension.toLowerCase() === 'pdf';
});
// Watch for file changes and load preview
watch(() => props.file, async (newFile) => {
if (newFile && props.modelValue) {
await loadPreview();
}
});
// Watch for dialog open and load preview
watch(() => props.modelValue, async (isOpen) => {
if (isOpen && props.file) {
await loadPreview();
}
});
// Load preview URL
const loadPreview = async () => {
if (!props.file) return;
loading.value = true;
error.value = '';
previewUrl.value = '';
// Clean up previous object URL if exists
if (objectUrl.value) {
URL.revokeObjectURL(objectUrl.value);
objectUrl.value = '';
}
try {
// For images and PDFs, fetch as blob and create object URL
if (isImage.value || isPdf.value) {
// Fetch the file as a blob, including bucket if specified
const bucket = props.file.bucket || 'client-portal';
const response = await fetch(`/api/files/proxy-preview?fileName=${encodeURIComponent(props.file.name)}&bucket=${bucket}`);
if (!response.ok) {
throw new Error('Failed to fetch file');
}
const blob = await response.blob();
// Create object URL from blob
objectUrl.value = URL.createObjectURL(blob);
previewUrl.value = objectUrl.value;
console.log('Created object URL for preview:', objectUrl.value);
// Set loading to false after a short delay to allow the component to render
setTimeout(() => {
loading.value = false;
}, 100);
} else {
throw new Error('File type does not support preview');
}
} catch (err: any) {
error.value = err.message || 'Failed to load preview';
loading.value = false;
}
};
// Handle preview load error
const handlePreviewError = (event: any) => {
console.error('Preview load error:', event);
console.error('Current preview URL:', previewUrl.value);
error.value = 'Failed to load preview';
loading.value = false;
};
// Download file (with special handling for Safari)
const downloadFile = async () => {
if (!props.file) return;
try {
// Extract clean filename for download
let filename = props.file.displayName;
if (!filename.includes('.') && props.file.extension) {
filename += '.' + props.file.extension;
}
// Check if Safari (iOS or desktop)
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
const bucket = props.file.bucket || 'client-portal';
if (isSafari) {
// For Safari, use location.href to force proper filename handling
const downloadUrl = `/api/files/proxy-download?fileName=${encodeURIComponent(props.file.name)}&bucket=${bucket}`;
window.location.href = downloadUrl;
} else {
// For other browsers, use blob approach
const response = await fetch(`/api/files/proxy-download?fileName=${encodeURIComponent(props.file.name)}&bucket=${bucket}`);
if (!response.ok) {
throw new Error('Failed to download file');
}
const blob = await response.blob();
// Create object URL from blob
const downloadUrl = URL.createObjectURL(blob);
// Create a link element
const link = document.createElement('a');
link.href = downloadUrl;
link.download = filename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
// Clean up
setTimeout(() => {
document.body.removeChild(link);
URL.revokeObjectURL(downloadUrl);
}, 100);
}
} catch (err) {
console.error('Failed to download file:', err);
}
};
// Close modal
const closeModal = () => {
isOpen.value = false;
previewUrl.value = '';
error.value = '';
// Clean up object URL
if (objectUrl.value) {
URL.revokeObjectURL(objectUrl.value);
objectUrl.value = '';
}
};
// Clean up on unmount
onUnmounted(() => {
if (objectUrl.value) {
URL.revokeObjectURL(objectUrl.value);
}
});
</script>
<style scoped>
.image-preview-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
max-height: 70vh;
overflow: auto;
background-color: #f5f5f5;
}
.image-preview {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
.pdf-preview-container {
width: 100%;
height: 70vh;
overflow: hidden;
position: relative;
}
.pdf-iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
-webkit-overflow-scrolling: touch; /* Enable smooth scrolling on iOS */
overflow: auto;
}
/* Mobile-specific adjustments */
@media (max-width: 600px) {
.pdf-preview-container {
height: 60vh; /* Slightly smaller on mobile for better UX */
}
.image-preview-container {
min-height: 300px;
max-height: 60vh;
}
}
</style>