vue · 8981 bytes Raw Blame History
1 <template>
2 <div
3 class="file-row group flex items-center px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 border-b border-gray-200 dark:border-gray-700 cursor-pointer"
4 :class="{
5 'bg-primary-50 dark:bg-primary-900/20': selected,
6 'border-primary-200 dark:border-primary-800': selected
7 }"
8 @click="$emit('click')"
9 @dblclick="$emit('dblclick')"
10 >
11 <!-- Selection checkbox -->
12 <div class="flex-shrink-0 mr-3">
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 </div>
29
30 <!-- File icon -->
31 <div class="flex-shrink-0 mr-3">
32 <div class="w-8 h-8 flex items-center justify-center">
33 <FolderIcon
34 v-if="file.type === 'directory'"
35 class="w-6 h-6 text-blue-600 dark:text-blue-400"
36 />
37 <component
38 v-else
39 :is="getFileIcon(file)"
40 class="w-6 h-6"
41 :class="getFileIconColor(file)"
42 />
43 </div>
44 </div>
45
46 <!-- File name and info -->
47 <div class="flex-1 min-w-0 mr-4">
48 <div class="flex items-center space-x-2">
49 <p class="text-sm font-medium text-gray-900 dark:text-white truncate">
50 {{ file.name }}
51 </p>
52
53 <!-- Encryption indicator -->
54 <LockClosedIcon
55 v-if="file.encrypted"
56 class="w-3 h-3 text-amber-600 dark:text-amber-400 flex-shrink-0"
57 title="Encrypted"
58 />
59 </div>
60
61 <!-- File type/description -->
62 <p class="text-xs text-gray-500 dark:text-gray-400 truncate">
63 {{ getFileDescription(file) }}
64 </p>
65 </div>
66
67 <!-- File size -->
68 <div class="flex-shrink-0 w-20 text-right mr-4">
69 <p class="text-sm text-gray-700 dark:text-gray-300">
70 {{ file.type === 'file' ? formatFileSize(file.size) : '—' }}
71 </p>
72 </div>
73
74 <!-- Last modified -->
75 <div class="flex-shrink-0 w-32 text-right mr-4">
76 <p class="text-sm text-gray-700 dark:text-gray-300">
77 {{ formatDate(file.lastModified) }}
78 </p>
79 </div>
80
81 <!-- Actions menu -->
82 <div class="flex-shrink-0">
83 <Menu as="div" class="relative">
84 <MenuButton
85 @click.stop
86 class="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-opacity"
87 >
88 <EllipsisHorizontalIcon class="w-5 h-5 text-gray-500" />
89 </MenuButton>
90
91 <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">
92 <MenuItem v-slot="{ active }">
93 <button
94 @click="$emit('preview')"
95 class="w-full text-left px-3 py-2 text-sm"
96 :class="active ? 'bg-gray-100 dark:bg-gray-700' : ''"
97 >
98 <EyeIcon class="w-4 h-4 inline mr-2" />
99 Preview
100 </button>
101 </MenuItem>
102 <MenuItem v-slot="{ active }">
103 <button
104 @click="$emit('download')"
105 class="w-full text-left px-3 py-2 text-sm"
106 :class="active ? 'bg-gray-100 dark:bg-gray-700' : ''"
107 >
108 <ArrowDownTrayIcon class="w-4 h-4 inline mr-2" />
109 Download
110 </button>
111 </MenuItem>
112 <MenuItem v-if="!file.encrypted" v-slot="{ active }">
113 <button
114 @click="$emit('share')"
115 class="w-full text-left px-3 py-2 text-sm"
116 :class="active ? 'bg-gray-100 dark:bg-gray-700' : ''"
117 >
118 <ShareIcon class="w-4 h-4 inline mr-2" />
119 Share
120 </button>
121 </MenuItem>
122 <MenuItem v-slot="{ active }">
123 <button
124 @click="$emit('rename')"
125 class="w-full text-left px-3 py-2 text-sm"
126 :class="active ? 'bg-gray-100 dark:bg-gray-700' : ''"
127 >
128 <PencilIcon class="w-4 h-4 inline mr-2" />
129 Rename
130 </button>
131 </MenuItem>
132 <MenuItem v-slot="{ active }">
133 <button
134 @click="$emit('delete')"
135 class="w-full text-left px-3 py-2 text-sm text-red-600 dark:text-red-400"
136 :class="active ? 'bg-red-50 dark:bg-red-900/20' : ''"
137 >
138 <TrashIcon class="w-4 h-4 inline mr-2" />
139 Delete
140 </button>
141 </MenuItem>
142 </MenuItems>
143 </Menu>
144 </div>
145 </div>
146 </template>
147
148 <script setup lang="ts">
149 import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
150 import {
151 CheckIcon,
152 EllipsisHorizontalIcon,
153 FolderIcon,
154 DocumentIcon,
155 PhotoIcon,
156 VideoCameraIcon,
157 MusicNoteIcon,
158 ArchiveBoxIcon,
159 CodeBracketIcon,
160 LockClosedIcon,
161 EyeIcon,
162 ArrowDownTrayIcon,
163 ShareIcon,
164 PencilIcon,
165 TrashIcon,
166 } from '@heroicons/vue/24/outline'
167 import type { FileItem } from '@shared/types'
168
169 interface Props {
170 file: FileItem
171 selected?: boolean
172 }
173
174 const props = withDefaults(defineProps<Props>(), {
175 selected: false,
176 })
177
178 const emit = defineEmits<{
179 click: []
180 dblclick: []
181 select: []
182 preview: []
183 download: []
184 share: []
185 rename: []
186 delete: []
187 }>()
188
189 function getFileIcon(file: FileItem) {
190 if (file.type === 'directory') return FolderIcon
191
192 const ext = file.name.split('.').pop()?.toLowerCase()
193 const mimeType = file.mimeType?.toLowerCase()
194
195 if (mimeType?.startsWith('image/') || ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'].includes(ext || '')) {
196 return PhotoIcon
197 }
198
199 if (mimeType?.startsWith('video/') || ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'].includes(ext || '')) {
200 return VideoCameraIcon
201 }
202
203 if (mimeType?.startsWith('audio/') || ['mp3', 'wav', 'flac', 'aac', 'ogg'].includes(ext || '')) {
204 return MusicNoteIcon
205 }
206
207 if (['js', 'ts', 'jsx', 'tsx', 'py', 'java', 'cpp', 'c', 'go', 'rs', 'php', 'rb', 'css', 'html', 'json', 'xml'].includes(ext || '')) {
208 return CodeBracketIcon
209 }
210
211 if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(ext || '')) {
212 return ArchiveBoxIcon
213 }
214
215 return DocumentIcon
216 }
217
218 function getFileIconColor(file: FileItem) {
219 const ext = file.name.split('.').pop()?.toLowerCase()
220 const mimeType = file.mimeType?.toLowerCase()
221
222 if (mimeType?.startsWith('image/')) return 'text-green-600 dark:text-green-400'
223 if (mimeType?.startsWith('video/')) return 'text-red-600 dark:text-red-400'
224 if (mimeType?.startsWith('audio/')) return 'text-purple-600 dark:text-purple-400'
225 if (['js', 'ts', 'jsx', 'tsx', 'py', 'java', 'cpp', 'c', 'go', 'rs', 'php', 'rb', 'css', 'html', 'json', 'xml'].includes(ext || '')) {
226 return 'text-blue-600 dark:text-blue-400'
227 }
228 if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(ext || '')) {
229 return 'text-orange-600 dark:text-orange-400'
230 }
231
232 return 'text-gray-600 dark:text-gray-400'
233 }
234
235 function getFileDescription(file: FileItem): string {
236 if (file.type === 'directory') {
237 return 'Folder'
238 }
239
240 const ext = file.name.split('.').pop()?.toLowerCase()
241
242 if (file.mimeType) {
243 const mimeType = file.mimeType.toLowerCase()
244 if (mimeType.startsWith('image/')) return 'Image'
245 if (mimeType.startsWith('video/')) return 'Video'
246 if (mimeType.startsWith('audio/')) return 'Audio'
247 if (mimeType === 'application/pdf') return 'PDF Document'
248 if (mimeType.includes('json')) return 'JSON File'
249 if (mimeType.includes('zip') || mimeType.includes('archive')) return 'Archive'
250 }
251
252 // Fallback to extension
253 const extMap: Record<string, string> = {
254 'txt': 'Text File',
255 'md': 'Markdown',
256 'js': 'JavaScript',
257 'ts': 'TypeScript',
258 'py': 'Python',
259 'java': 'Java',
260 'cpp': 'C++',
261 'c': 'C',
262 'go': 'Go',
263 'rs': 'Rust',
264 'php': 'PHP',
265 'rb': 'Ruby',
266 'css': 'CSS',
267 'html': 'HTML',
268 'json': 'JSON',
269 'xml': 'XML',
270 'zip': 'ZIP Archive',
271 'rar': 'RAR Archive',
272 '7z': '7-Zip Archive',
273 'tar': 'TAR Archive',
274 'gz': 'Gzip Archive',
275 }
276
277 return extMap[ext || ''] || (ext ? `${ext.toUpperCase()} File` : 'File')
278 }
279
280 function formatFileSize(bytes: number): string {
281 if (bytes === 0) return '0 B'
282 const k = 1024
283 const sizes = ['B', 'KB', 'MB', 'GB']
284 const i = Math.floor(Math.log(bytes) / Math.log(k))
285 return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
286 }
287
288 function formatDate(date: Date): string {
289 return new Intl.DateTimeFormat('en-US', {
290 month: 'short',
291 day: 'numeric',
292 year: 'numeric',
293 }).format(new Date(date))
294 }
295 </script>