vue · 8361 bytes Raw Blame History
1 <template>
2 <div
3 class="file-card group cursor-pointer border border-gray-200 dark:border-gray-700 rounded-lg p-3 hover:border-gray-300 dark:hover:border-gray-600 hover:shadow-md transition-all"
4 :class="{
5 'ring-2 ring-primary-500 border-primary-500': selected,
6 'bg-primary-50 dark:bg-primary-900/20': selected
7 }"
8 @click="$emit('click')"
9 @dblclick="$emit('dblclick')"
10 >
11 <!-- Selection checkbox -->
12 <div class="flex items-start justify-between mb-2">
13 <button
14 @click.stop="$emit('select')"
15 class="opacity-0 group-hover:opacity-100 transition-opacity"
16 :class="{ 'opacity-100': selected }"
17 >
18 <div
19 class="w-4 h-4 border-2 rounded border-gray-300 dark:border-gray-600 flex items-center justify-center"
20 :class="{
21 'bg-primary-500 border-primary-500': selected,
22 'hover:border-gray-400 dark:hover:border-gray-500': !selected
23 }"
24 >
25 <CheckIcon v-if="selected" class="w-3 h-3 text-white" />
26 </div>
27 </button>
28
29 <!-- Actions menu -->
30 <Menu as="div" class="relative">
31 <MenuButton
32 @click.stop
33 class="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-opacity"
34 >
35 <EllipsisVerticalIcon class="w-4 h-4 text-gray-500" />
36 </MenuButton>
37
38 <MenuItems class="absolute right-0 mt-1 w-36 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg z-20">
39 <MenuItem v-slot="{ active }">
40 <button
41 @click="$emit('preview')"
42 class="w-full text-left px-3 py-2 text-sm"
43 :class="active ? 'bg-gray-100 dark:bg-gray-700' : ''"
44 >
45 <EyeIcon class="w-4 h-4 inline mr-2" />
46 Preview
47 </button>
48 </MenuItem>
49 <MenuItem v-slot="{ active }">
50 <button
51 @click="$emit('download')"
52 class="w-full text-left px-3 py-2 text-sm"
53 :class="active ? 'bg-gray-100 dark:bg-gray-700' : ''"
54 >
55 <ArrowDownTrayIcon class="w-4 h-4 inline mr-2" />
56 Download
57 </button>
58 </MenuItem>
59 <MenuItem v-if="!file.encrypted" v-slot="{ active }">
60 <button
61 @click="$emit('share')"
62 class="w-full text-left px-3 py-2 text-sm"
63 :class="active ? 'bg-gray-100 dark:bg-gray-700' : ''"
64 >
65 <ShareIcon class="w-4 h-4 inline mr-2" />
66 Share
67 </button>
68 </MenuItem>
69 <MenuItem v-slot="{ active }">
70 <button
71 @click="$emit('rename')"
72 class="w-full text-left px-3 py-2 text-sm"
73 :class="active ? 'bg-gray-100 dark:bg-gray-700' : ''"
74 >
75 <PencilIcon class="w-4 h-4 inline mr-2" />
76 Rename
77 </button>
78 </MenuItem>
79 <MenuItem v-slot="{ active }">
80 <button
81 @click="$emit('delete')"
82 class="w-full text-left px-3 py-2 text-sm text-red-600 dark:text-red-400"
83 :class="active ? 'bg-red-50 dark:bg-red-900/20' : ''"
84 >
85 <TrashIcon class="w-4 h-4 inline mr-2" />
86 Delete
87 </button>
88 </MenuItem>
89 </MenuItems>
90 </Menu>
91 </div>
92
93 <!-- File icon and preview -->
94 <div class="flex flex-col items-center mb-3">
95 <!-- Thumbnail or icon -->
96 <div class="w-16 h-16 flex items-center justify-center rounded-lg mb-2"
97 :class="file.type === 'directory' ? 'bg-blue-100 dark:bg-blue-900/30' : 'bg-gray-100 dark:bg-gray-700'">
98
99 <!-- Directory icon -->
100 <FolderIcon
101 v-if="file.type === 'directory'"
102 class="w-10 h-10 text-blue-600 dark:text-blue-400"
103 />
104
105 <!-- File icons based on type -->
106 <component
107 v-else
108 :is="getFileIcon(file)"
109 class="w-10 h-10"
110 :class="getFileIconColor(file)"
111 />
112 </div>
113
114 <!-- Encryption indicator -->
115 <div v-if="file.encrypted" class="flex items-center text-xs text-amber-600 dark:text-amber-400 mb-1">
116 <LockClosedIcon class="w-3 h-3 mr-1" />
117 <span>Encrypted</span>
118 </div>
119 </div>
120
121 <!-- File info -->
122 <div class="text-center space-y-1">
123 <!-- File name -->
124 <h3 class="text-sm font-medium text-gray-900 dark:text-white truncate" :title="file.name">
125 {{ file.name }}
126 </h3>
127
128 <!-- File size and date -->
129 <div class="text-xs text-gray-500 dark:text-gray-400 space-y-0.5">
130 <div v-if="file.type === 'file'">
131 {{ formatFileSize(file.size) }}
132 </div>
133 <div>
134 {{ formatDate(file.lastModified) }}
135 </div>
136 </div>
137 </div>
138 </div>
139 </template>
140
141 <script setup lang="ts">
142 import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
143 import {
144 CheckIcon,
145 EllipsisVerticalIcon,
146 FolderIcon,
147 DocumentIcon,
148 PhotoIcon,
149 VideoCameraIcon,
150 MusicNoteIcon,
151 ArchiveBoxIcon,
152 CodeBracketIcon,
153 LockClosedIcon,
154 EyeIcon,
155 ArrowDownTrayIcon,
156 ShareIcon,
157 PencilIcon,
158 TrashIcon,
159 } from '@heroicons/vue/24/outline'
160 import type { FileItem } from '@shared/types'
161
162 interface Props {
163 file: FileItem
164 selected?: boolean
165 }
166
167 const props = withDefaults(defineProps<Props>(), {
168 selected: false,
169 })
170
171 const emit = defineEmits<{
172 click: []
173 dblclick: []
174 select: []
175 preview: []
176 download: []
177 share: []
178 rename: []
179 delete: []
180 }>()
181
182 function getFileIcon(file: FileItem) {
183 if (file.type === 'directory') return FolderIcon
184
185 const ext = file.name.split('.').pop()?.toLowerCase()
186 const mimeType = file.mimeType?.toLowerCase()
187
188 // Images
189 if (mimeType?.startsWith('image/') || ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'].includes(ext || '')) {
190 return PhotoIcon
191 }
192
193 // Videos
194 if (mimeType?.startsWith('video/') || ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'].includes(ext || '')) {
195 return VideoCameraIcon
196 }
197
198 // Audio
199 if (mimeType?.startsWith('audio/') || ['mp3', 'wav', 'flac', 'aac', 'ogg'].includes(ext || '')) {
200 return MusicNoteIcon
201 }
202
203 // Code files
204 if (['js', 'ts', 'jsx', 'tsx', 'py', 'java', 'cpp', 'c', 'go', 'rs', 'php', 'rb', 'css', 'html', 'json', 'xml'].includes(ext || '')) {
205 return CodeBracketIcon
206 }
207
208 // Archives
209 if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(ext || '')) {
210 return ArchiveBoxIcon
211 }
212
213 return DocumentIcon
214 }
215
216 function getFileIconColor(file: FileItem) {
217 const ext = file.name.split('.').pop()?.toLowerCase()
218 const mimeType = file.mimeType?.toLowerCase()
219
220 if (mimeType?.startsWith('image/') || ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'].includes(ext || '')) {
221 return 'text-green-600 dark:text-green-400'
222 }
223
224 if (mimeType?.startsWith('video/') || ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'].includes(ext || '')) {
225 return 'text-red-600 dark:text-red-400'
226 }
227
228 if (mimeType?.startsWith('audio/') || ['mp3', 'wav', 'flac', 'aac', 'ogg'].includes(ext || '')) {
229 return 'text-purple-600 dark:text-purple-400'
230 }
231
232 if (['js', 'ts', 'jsx', 'tsx', 'py', 'java', 'cpp', 'c', 'go', 'rs', 'php', 'rb', 'css', 'html', 'json', 'xml'].includes(ext || '')) {
233 return 'text-blue-600 dark:text-blue-400'
234 }
235
236 if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(ext || '')) {
237 return 'text-orange-600 dark:text-orange-400'
238 }
239
240 return 'text-gray-600 dark:text-gray-400'
241 }
242
243 function formatFileSize(bytes: number): string {
244 if (bytes === 0) return '0 B'
245 const k = 1024
246 const sizes = ['B', 'KB', 'MB', 'GB']
247 const i = Math.floor(Math.log(bytes) / Math.log(k))
248 return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
249 }
250
251 function formatDate(date: Date): string {
252 const now = new Date()
253 const diff = now.getTime() - new Date(date).getTime()
254 const days = Math.floor(diff / (1000 * 60 * 60 * 24))
255
256 if (days === 0) return 'Today'
257 if (days === 1) return 'Yesterday'
258 if (days < 7) return `${days} days ago`
259
260 return new Intl.DateTimeFormat('en-US', {
261 month: 'short',
262 day: 'numeric',
263 year: now.getFullYear() !== new Date(date).getFullYear() ? 'numeric' : undefined,
264 }).format(new Date(date))
265 }
266 </script>