vue · 11711 bytes Raw Blame History
1 <template>
2 <Modal :show="!!file" @close="$emit('close')" :closable="!loading">
3 <template #title>
4 <div class="flex items-center space-x-3">
5 <component :is="getFileIcon(file)" class="w-6 h-6 text-gray-600 dark:text-gray-400" />
6 <span>{{ file?.name }}</span>
7 </div>
8 </template>
9
10 <div class="min-h-[60vh] max-h-[80vh] overflow-hidden">
11 <!-- Loading state -->
12 <div v-if="loading" class="flex items-center justify-center h-64">
13 <div class="flex items-center space-x-3">
14 <div class="spinner w-6 h-6"></div>
15 <span class="text-gray-600 dark:text-gray-400">Loading preview...</span>
16 </div>
17 </div>
18
19 <!-- Error state -->
20 <div v-else-if="error" class="flex flex-col items-center justify-center h-64 text-center">
21 <ExclamationTriangleIcon class="w-12 h-12 text-red-500 mb-3" />
22 <p class="text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
23 <button @click="loadPreview" class="btn btn-outline">
24 Try Again
25 </button>
26 </div>
27
28 <!-- Preview content -->
29 <div v-else class="preview-content">
30 <!-- Image preview -->
31 <div v-if="previewType === 'image'" class="flex items-center justify-center bg-gray-100 dark:bg-gray-800 rounded-lg overflow-hidden">
32 <img
33 :src="previewUrl"
34 :alt="file?.name"
35 class="max-w-full max-h-[70vh] object-contain"
36 @load="handleImageLoad"
37 @error="handleImageError"
38 />
39 </div>
40
41 <!-- Text preview -->
42 <div v-else-if="previewType === 'text'" class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 overflow-auto max-h-[70vh]">
43 <pre class="text-sm text-gray-800 dark:text-gray-200 whitespace-pre-wrap font-mono">{{ textContent }}</pre>
44 </div>
45
46 <!-- PDF preview -->
47 <div v-else-if="previewType === 'pdf'" class="bg-gray-100 dark:bg-gray-800 rounded-lg overflow-hidden">
48 <iframe
49 :src="previewUrl"
50 class="w-full h-[70vh] border-0"
51 title="PDF Preview"
52 ></iframe>
53 </div>
54
55 <!-- Video preview -->
56 <div v-else-if="previewType === 'video'" class="bg-black rounded-lg overflow-hidden flex items-center justify-center">
57 <video
58 :src="previewUrl"
59 controls
60 class="max-w-full max-h-[70vh]"
61 preload="metadata"
62 >
63 Your browser does not support video playback.
64 </video>
65 </div>
66
67 <!-- Audio preview -->
68 <div v-else-if="previewType === 'audio'" class="flex flex-col items-center justify-center h-64 space-y-4">
69 <MusicNoteIcon class="w-16 h-16 text-gray-400" />
70 <audio
71 :src="previewUrl"
72 controls
73 class="w-full max-w-md"
74 preload="metadata"
75 >
76 Your browser does not support audio playback.
77 </audio>
78 </div>
79
80 <!-- Archive preview -->
81 <div v-else-if="previewType === 'archive'" class="space-y-4">
82 <div class="flex items-center space-x-2 text-sm text-gray-600 dark:text-gray-400">
83 <ArchiveBoxIcon class="w-5 h-5" />
84 <span>Archive contents ({{ archiveContents.length }} items)</span>
85 </div>
86 <div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 max-h-96 overflow-auto">
87 <div class="space-y-1">
88 <div
89 v-for="item in archiveContents"
90 :key="item.path"
91 class="flex items-center space-x-2 text-sm"
92 >
93 <component
94 :is="item.isDirectory ? FolderIcon : DocumentIcon"
95 class="w-4 h-4 text-gray-400"
96 />
97 <span class="font-mono text-gray-700 dark:text-gray-300">{{ item.path }}</span>
98 <span v-if="!item.isDirectory" class="text-gray-500">
99 ({{ formatFileSize(item.size) }})
100 </span>
101 </div>
102 </div>
103 </div>
104 </div>
105
106 <!-- JSON preview -->
107 <div v-else-if="previewType === 'json'" class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 overflow-auto max-h-[70vh]">
108 <JsonViewer :data="jsonData" />
109 </div>
110
111 <!-- Not supported -->
112 <div v-else class="flex flex-col items-center justify-center h-64 text-center">
113 <DocumentIcon class="w-16 h-16 text-gray-300 dark:text-gray-600 mb-4" />
114 <h3 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-2">
115 Preview not available
116 </h3>
117 <p class="text-gray-500 dark:text-gray-400 mb-6">
118 This file type cannot be previewed in the browser.
119 </p>
120 <button @click="downloadFile" class="btn btn-primary">
121 <ArrowDownTrayIcon class="w-4 h-4 mr-2" />
122 Download File
123 </button>
124 </div>
125 </div>
126
127 <!-- File info -->
128 <div v-if="file && !loading" class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
129 <div class="grid grid-cols-2 gap-4 text-sm">
130 <div>
131 <span class="text-gray-500 dark:text-gray-400">Size:</span>
132 <span class="ml-2 text-gray-700 dark:text-gray-300">{{ formatFileSize(file.size) }}</span>
133 </div>
134 <div>
135 <span class="text-gray-500 dark:text-gray-400">Modified:</span>
136 <span class="ml-2 text-gray-700 dark:text-gray-300">{{ formatDate(file.lastModified) }}</span>
137 </div>
138 <div>
139 <span class="text-gray-500 dark:text-gray-400">Type:</span>
140 <span class="ml-2 text-gray-700 dark:text-gray-300">{{ file.mimeType || 'Unknown' }}</span>
141 </div>
142 <div>
143 <span class="text-gray-500 dark:text-gray-400">Encrypted:</span>
144 <span class="ml-2 text-gray-700 dark:text-gray-300">{{ file.encrypted ? 'Yes' : 'No' }}</span>
145 </div>
146 </div>
147 </div>
148 </div>
149
150 <template #footer>
151 <div class="flex justify-between items-center w-full">
152 <div class="flex items-center space-x-2">
153 <button
154 v-if="file"
155 @click="downloadFile"
156 class="btn btn-outline"
157 >
158 <ArrowDownTrayIcon class="w-4 h-4 mr-2" />
159 Download
160 </button>
161 <button
162 v-if="file && canShare"
163 @click="shareFile"
164 class="btn btn-outline"
165 >
166 <ShareIcon class="w-4 h-4 mr-2" />
167 Share
168 </button>
169 </div>
170 <button @click="$emit('close')" class="btn btn-primary">
171 Close
172 </button>
173 </div>
174 </template>
175 </Modal>
176 </template>
177
178 <script setup lang="ts">
179 import { ref, computed, watch, onUnmounted } from 'vue'
180 import { apiClient } from '@/services/api'
181 import {
182 ExclamationTriangleIcon,
183 DocumentIcon,
184 PhotoIcon,
185 VideoCameraIcon,
186 MusicNoteIcon,
187 ArchiveBoxIcon,
188 FolderIcon,
189 ArrowDownTrayIcon,
190 ShareIcon,
191 CodeBracketIcon,
192 } from '@heroicons/vue/24/outline'
193 import Modal from './Modal.vue'
194 import JsonViewer from './JsonViewer.vue'
195 import type { FileItem } from '@shared/types'
196
197 interface Props {
198 file: FileItem | null
199 }
200
201 const props = defineProps<Props>()
202
203 const emit = defineEmits<{
204 close: []
205 share: [file: FileItem]
206 }>()
207
208 // State
209 const loading = ref(false)
210 const error = ref('')
211 const previewUrl = ref('')
212 const textContent = ref('')
213 const jsonData = ref<any>(null)
214 const archiveContents = ref<Array<{ path: string; size: number; isDirectory: boolean }>>([])
215
216 // Computed
217 const previewType = computed(() => {
218 if (!props.file?.mimeType) return 'unknown'
219
220 const mimeType = props.file.mimeType.toLowerCase()
221
222 if (mimeType.startsWith('image/')) return 'image'
223 if (mimeType.startsWith('video/')) return 'video'
224 if (mimeType.startsWith('audio/')) return 'audio'
225 if (mimeType === 'application/pdf') return 'pdf'
226 if (mimeType === 'application/json' || mimeType === 'text/json') return 'json'
227 if (mimeType.startsWith('text/') || mimeType === 'application/javascript') return 'text'
228 if (mimeType.includes('zip') || mimeType.includes('tar') || mimeType.includes('archive')) return 'archive'
229
230 return 'unknown'
231 })
232
233 const canShare = computed(() => {
234 return props.file && !props.file.encrypted
235 })
236
237 // Methods
238 function getFileIcon(file: FileItem | null) {
239 if (!file) return DocumentIcon
240
241 const type = previewType.value
242 switch (type) {
243 case 'image': return PhotoIcon
244 case 'video': return VideoCameraIcon
245 case 'audio': return MusicNoteIcon
246 case 'archive': return ArchiveBoxIcon
247 case 'json': return CodeBracketIcon
248 default: return DocumentIcon
249 }
250 }
251
252 async function loadPreview() {
253 if (!props.file) return
254
255 loading.value = true
256 error.value = ''
257
258 try {
259 const blob = await apiClient.downloadFile(props.file.id)
260 previewUrl.value = URL.createObjectURL(blob)
261
262 // Load content based on type
263 if (previewType.value === 'text' || previewType.value === 'json') {
264 const text = await blob.text()
265
266 if (previewType.value === 'json') {
267 try {
268 jsonData.value = JSON.parse(text)
269 } catch {
270 textContent.value = text
271 }
272 } else {
273 textContent.value = text.slice(0, 50000) // Limit to 50KB for display
274 }
275 }
276
277 // TODO: Implement archive contents parsing for ZIP files
278 if (previewType.value === 'archive') {
279 // This would require a ZIP parsing library
280 archiveContents.value = [
281 { path: 'example.txt', size: 1024, isDirectory: false },
282 ]
283 }
284
285 } catch (err: any) {
286 error.value = err.message || 'Failed to load preview'
287 console.error('Preview failed:', err)
288 } finally {
289 loading.value = false
290 }
291 }
292
293 function handleImageLoad() {
294 // Image loaded successfully
295 }
296
297 function handleImageError() {
298 error.value = 'Failed to load image'
299 }
300
301 async function downloadFile() {
302 if (!props.file) return
303
304 try {
305 const blob = await apiClient.downloadFile(props.file.id)
306 const url = URL.createObjectURL(blob)
307 const link = document.createElement('a')
308 link.href = url
309 link.download = props.file.name
310 document.body.appendChild(link)
311 link.click()
312 document.body.removeChild(link)
313 URL.revokeObjectURL(url)
314 } catch (error) {
315 console.error('Download failed:', error)
316 if (window.$notify) {
317 window.$notify.error('Failed to download file')
318 }
319 }
320 }
321
322 function shareFile() {
323 if (props.file) {
324 emit('share', props.file)
325 }
326 }
327
328 function formatFileSize(bytes: number): string {
329 if (bytes === 0) return '0 B'
330 const k = 1024
331 const sizes = ['B', 'KB', 'MB', 'GB']
332 const i = Math.floor(Math.log(bytes) / Math.log(k))
333 return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
334 }
335
336 function formatDate(date: Date): string {
337 return new Intl.DateTimeFormat('en-US', {
338 year: 'numeric',
339 month: 'short',
340 day: 'numeric',
341 hour: '2-digit',
342 minute: '2-digit',
343 }).format(new Date(date))
344 }
345
346 // Watch for file changes
347 watch(() => props.file, (newFile) => {
348 if (newFile) {
349 loadPreview()
350 } else {
351 // Clean up
352 if (previewUrl.value) {
353 URL.revokeObjectURL(previewUrl.value)
354 previewUrl.value = ''
355 }
356 textContent.value = ''
357 jsonData.value = null
358 archiveContents.value = []
359 }
360 }, { immediate: true })
361
362 // Cleanup on unmount
363 onUnmounted(() => {
364 if (previewUrl.value) {
365 URL.revokeObjectURL(previewUrl.value)
366 }
367 })
368 </script>
369
370 <style scoped>
371 .preview-content {
372 @apply flex flex-col space-y-4;
373 }
374
375 pre {
376 @apply break-words whitespace-pre-wrap;
377 }
378 </style>