361 lines
9.5 KiB
Vue
361 lines
9.5 KiB
Vue
<template>
|
||
<div v-if="modelValue" class="modal-overlay" @click.self="closeModal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h3 class="modal-title">
|
||
Receipt {{ currentIndex + 1 }} of {{ receipts.length }}
|
||
</h3>
|
||
<button @click="closeModal" class="btn-close">
|
||
<Icon name="mdi:close" class="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div class="modal-body">
|
||
<div v-if="currentReceipt" class="receipt-viewer">
|
||
<!-- Receipt Image -->
|
||
<div class="receipt-image-container">
|
||
<img
|
||
:src="currentReceipt.signedUrl || currentReceipt.url"
|
||
:alt="currentReceipt.title || `Receipt ${currentIndex + 1}`"
|
||
class="receipt-image"
|
||
@error="handleImageError"
|
||
@load="handleImageLoad"
|
||
/>
|
||
|
||
<!-- Loading overlay -->
|
||
<div v-if="imageLoading" class="loading-overlay">
|
||
<span class="loading loading-spinner loading-lg"></span>
|
||
</div>
|
||
|
||
<!-- Error overlay -->
|
||
<div v-if="imageError" class="error-overlay">
|
||
<Icon name="mdi:image-broken" class="w-12 h-12 text-red-400 mb-2" />
|
||
<p class="text-red-500 text-sm">Failed to load image</p>
|
||
<button @click="retryLoad" class="btn btn-sm btn-outline mt-2">
|
||
Retry
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Receipt Info -->
|
||
<div class="receipt-info">
|
||
<div class="info-item">
|
||
<span class="info-label">File Name:</span>
|
||
<span class="info-value">{{ currentReceipt.title || 'Unknown' }}</span>
|
||
</div>
|
||
|
||
<div class="info-item">
|
||
<span class="info-label">File Size:</span>
|
||
<span class="info-value">{{ formatFileSize(currentReceipt.size) }}</span>
|
||
</div>
|
||
|
||
<div class="info-item">
|
||
<span class="info-label">Type:</span>
|
||
<span class="info-value">{{ currentReceipt.mimetype || 'Unknown' }}</span>
|
||
</div>
|
||
|
||
<div v-if="currentReceipt.width && currentReceipt.height" class="info-item">
|
||
<span class="info-label">Dimensions:</span>
|
||
<span class="info-value">{{ currentReceipt.width }} × {{ currentReceipt.height }}px</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Navigation Controls -->
|
||
<div v-if="receipts.length > 1" class="navigation-controls">
|
||
<button
|
||
@click="previousReceipt"
|
||
:disabled="currentIndex === 0"
|
||
class="nav-btn"
|
||
title="Previous receipt"
|
||
>
|
||
<Icon name="mdi:chevron-left" class="w-5 h-5" />
|
||
</button>
|
||
|
||
<div class="receipt-dots">
|
||
<button
|
||
v-for="(receipt, index) in receipts"
|
||
:key="receipt.id || index"
|
||
@click="goToReceipt(index)"
|
||
:class="['dot', { active: index === currentIndex }]"
|
||
:title="`Go to receipt ${index + 1}`"
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
@click="nextReceipt"
|
||
:disabled="currentIndex === receipts.length - 1"
|
||
class="nav-btn"
|
||
title="Next receipt"
|
||
>
|
||
<Icon name="mdi:chevron-right" class="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button @click="closeModal" class="btn btn-ghost">
|
||
Close
|
||
</button>
|
||
|
||
<button
|
||
v-if="currentReceipt"
|
||
@click="downloadReceipt"
|
||
class="btn btn-outline"
|
||
>
|
||
<Icon name="mdi:download" class="w-4 h-4" />
|
||
Download
|
||
</button>
|
||
|
||
<button
|
||
v-if="currentReceipt"
|
||
@click="openInNewTab"
|
||
class="btn btn-primary"
|
||
>
|
||
<Icon name="mdi:open-in-new" class="w-4 h-4" />
|
||
Open in New Tab
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch } from 'vue';
|
||
import type { ExpenseReceipt } from '@/utils/types';
|
||
|
||
// Props
|
||
interface Props {
|
||
modelValue: boolean;
|
||
receipts: ExpenseReceipt[];
|
||
currentIndex?: number;
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
currentIndex: 0
|
||
});
|
||
|
||
// Emits
|
||
const emit = defineEmits<{
|
||
'update:modelValue': [value: boolean];
|
||
'update:currentIndex': [index: number];
|
||
}>();
|
||
|
||
// Reactive state
|
||
const imageLoading = ref(true);
|
||
const imageError = ref(false);
|
||
|
||
// Computed
|
||
const currentReceipt = computed(() => {
|
||
return props.receipts[props.currentIndex] || null;
|
||
});
|
||
|
||
// Methods
|
||
const closeModal = () => {
|
||
emit('update:modelValue', false);
|
||
};
|
||
|
||
const handleImageLoad = () => {
|
||
imageLoading.value = false;
|
||
imageError.value = false;
|
||
};
|
||
|
||
const handleImageError = () => {
|
||
imageLoading.value = false;
|
||
imageError.value = true;
|
||
};
|
||
|
||
const retryLoad = () => {
|
||
imageLoading.value = true;
|
||
imageError.value = false;
|
||
|
||
// Force reload by updating the image src
|
||
const img = document.querySelector('.receipt-image') as HTMLImageElement;
|
||
if (img && currentReceipt.value) {
|
||
const url = currentReceipt.value.signedUrl || currentReceipt.value.url;
|
||
img.src = url + '?t=' + Date.now(); // Add timestamp to force reload
|
||
}
|
||
};
|
||
|
||
const previousReceipt = () => {
|
||
if (props.currentIndex > 0) {
|
||
emit('update:currentIndex', props.currentIndex - 1);
|
||
}
|
||
};
|
||
|
||
const nextReceipt = () => {
|
||
if (props.currentIndex < props.receipts.length - 1) {
|
||
emit('update:currentIndex', props.currentIndex + 1);
|
||
}
|
||
};
|
||
|
||
const goToReceipt = (index: number) => {
|
||
emit('update:currentIndex', index);
|
||
};
|
||
|
||
const formatFileSize = (bytes: number) => {
|
||
if (!bytes) return 'Unknown size';
|
||
|
||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
||
};
|
||
|
||
const downloadReceipt = () => {
|
||
if (!currentReceipt.value?.signedUrl) return;
|
||
|
||
const link = document.createElement('a');
|
||
link.href = currentReceipt.value.signedUrl;
|
||
link.download = currentReceipt.value.title || `receipt-${props.currentIndex + 1}`;
|
||
link.target = '_blank';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
};
|
||
|
||
const openInNewTab = () => {
|
||
if (!currentReceipt.value?.signedUrl) return;
|
||
|
||
window.open(currentReceipt.value.signedUrl, '_blank');
|
||
};
|
||
|
||
// Watch for receipt changes
|
||
watch(() => props.currentIndex, () => {
|
||
imageLoading.value = true;
|
||
imageError.value = false;
|
||
}, { immediate: true });
|
||
|
||
// Keyboard navigation
|
||
const handleKeydown = (event: KeyboardEvent) => {
|
||
if (!props.modelValue) return;
|
||
|
||
switch (event.key) {
|
||
case 'Escape':
|
||
closeModal();
|
||
break;
|
||
case 'ArrowLeft':
|
||
previousReceipt();
|
||
break;
|
||
case 'ArrowRight':
|
||
nextReceipt();
|
||
break;
|
||
}
|
||
};
|
||
|
||
// Add keyboard event listener when modal is open
|
||
watch(() => props.modelValue, (isOpen) => {
|
||
if (isOpen) {
|
||
document.addEventListener('keydown', handleKeydown);
|
||
} else {
|
||
document.removeEventListener('keydown', handleKeydown);
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.modal-overlay {
|
||
@apply fixed inset-0 bg-black/80 flex items-center justify-center p-4 z-50;
|
||
}
|
||
|
||
.modal-content {
|
||
@apply bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden mx-2 sm:mx-4;
|
||
}
|
||
|
||
.modal-header {
|
||
@apply flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700;
|
||
}
|
||
|
||
.modal-title {
|
||
@apply text-lg font-semibold text-gray-900 dark:text-white;
|
||
}
|
||
|
||
.btn-close {
|
||
@apply p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors;
|
||
}
|
||
|
||
.modal-body {
|
||
@apply p-4 overflow-y-auto;
|
||
}
|
||
|
||
.receipt-viewer {
|
||
@apply space-y-4;
|
||
}
|
||
|
||
.receipt-image-container {
|
||
@apply relative bg-gray-100 dark:bg-gray-700 rounded-lg overflow-hidden;
|
||
min-height: 400px;
|
||
}
|
||
|
||
.receipt-image {
|
||
@apply w-full h-auto max-h-[60vh] object-contain;
|
||
}
|
||
|
||
.loading-overlay {
|
||
@apply absolute inset-0 flex items-center justify-center bg-gray-100 dark:bg-gray-700;
|
||
}
|
||
|
||
.error-overlay {
|
||
@apply absolute inset-0 flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-700 text-center p-8;
|
||
}
|
||
|
||
.receipt-info {
|
||
@apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg;
|
||
}
|
||
|
||
.info-item {
|
||
@apply space-y-1;
|
||
}
|
||
|
||
.info-label {
|
||
@apply text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide;
|
||
}
|
||
|
||
.info-value {
|
||
@apply text-sm text-gray-900 dark:text-white;
|
||
}
|
||
|
||
.navigation-controls {
|
||
@apply flex items-center justify-center gap-4 p-4;
|
||
}
|
||
|
||
.nav-btn {
|
||
@apply p-2 rounded-full bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors;
|
||
}
|
||
|
||
.receipt-dots {
|
||
@apply flex items-center gap-2;
|
||
}
|
||
|
||
.dot {
|
||
@apply w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors;
|
||
}
|
||
|
||
.dot.active {
|
||
@apply bg-primary;
|
||
}
|
||
|
||
.modal-footer {
|
||
@apply flex items-center justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700;
|
||
}
|
||
|
||
.btn {
|
||
@apply inline-flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors;
|
||
}
|
||
|
||
.btn-ghost {
|
||
@apply text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700;
|
||
}
|
||
|
||
.btn-outline {
|
||
@apply border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700;
|
||
}
|
||
|
||
.btn-primary {
|
||
@apply bg-primary text-white hover:bg-primary/90;
|
||
}
|
||
|
||
.btn-sm {
|
||
@apply px-3 py-1 text-sm;
|
||
}
|
||
</style>
|