323 lines
7.9 KiB
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>
|