zephyrfs/zephyrfs-web / 6f3d442

Browse files

Phase 3.3: Advanced file operations with folder creation, bulk download/delete, file preview system, enhanced UI components

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
6f3d4428d1c13216b45f95e3026de25440d3e0ec
Parents
c5369ca
Tree
d9055be

10 changed files

StatusFile+-
A client/src/components/CreateFolderModal.vue 196 0
M client/src/components/FileBrowser.vue 85 10
A client/src/components/FileCard.vue 266 0
A client/src/components/FilePreview.vue 378 0
A client/src/components/FileRow.vue 295 0
A client/src/components/JsonViewer.vue 164 0
M server/package.json 3 1
A server/src/routes/bulk.ts 184 0
A server/src/routes/folders.ts 211 0
M server/src/routes/index.ts 4 0
client/src/components/CreateFolderModal.vueadded
@@ -0,0 +1,196 @@
1
+<template>
2
+  <Modal @close="$emit('close')">
3
+    <template #title>Create New Folder</template>
4
+
5
+    <form @submit.prevent="handleCreate" class="space-y-4">
6
+      <!-- Folder name input -->
7
+      <div>
8
+        <label for="folderName" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
9
+          Folder Name
10
+        </label>
11
+        <input
12
+          id="folderName"
13
+          ref="nameInput"
14
+          v-model="form.name"
15
+          type="text"
16
+          required
17
+          class="input"
18
+          :class="{ 'border-red-500': errors.name }"
19
+          placeholder="Enter folder name"
20
+          @input="validateName"
21
+        />
22
+        <p v-if="errors.name" class="mt-1 text-sm text-red-600 dark:text-red-400">
23
+          {{ errors.name }}
24
+        </p>
25
+      </div>
26
+
27
+      <!-- Current path display -->
28
+      <div class="p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
29
+        <p class="text-sm text-gray-600 dark:text-gray-400">
30
+          Create in: <span class="font-mono">{{ displayPath }}</span>
31
+        </p>
32
+      </div>
33
+
34
+      <!-- Options -->
35
+      <div class="space-y-3">
36
+        <label class="flex items-center">
37
+          <input
38
+            v-model="form.encrypted"
39
+            type="checkbox"
40
+            class="mr-2 rounded border-gray-300 focus:ring-primary-500"
41
+          />
42
+          <span class="text-sm text-gray-700 dark:text-gray-300">
43
+            Create encrypted folder
44
+          </span>
45
+        </label>
46
+
47
+        <div v-if="form.encrypted" class="ml-6 text-xs text-gray-500 dark:text-gray-400">
48
+          All files uploaded to this folder will be automatically encrypted
49
+        </div>
50
+      </div>
51
+
52
+      <!-- Error display -->
53
+      <div v-if="error" class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
54
+        <p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
55
+      </div>
56
+    </form>
57
+
58
+    <template #footer>
59
+      <button
60
+        type="button"
61
+        @click="$emit('close')"
62
+        class="btn btn-outline mr-3"
63
+      >
64
+        Cancel
65
+      </button>
66
+      <button
67
+        @click="handleCreate"
68
+        :disabled="loading || !isValid"
69
+        class="btn btn-primary"
70
+        :class="{ 'opacity-50 cursor-not-allowed': loading || !isValid }"
71
+      >
72
+        <div v-if="loading" class="spinner w-4 h-4 mr-2"></div>
73
+        {{ loading ? 'Creating...' : 'Create Folder' }}
74
+      </button>
75
+    </template>
76
+  </Modal>
77
+</template>
78
+
79
+<script setup lang="ts">
80
+import { ref, computed, onMounted, nextTick } from 'vue'
81
+import { apiClient } from '@/services/api'
82
+import Modal from './Modal.vue'
83
+
84
+interface Props {
85
+  currentPath?: string
86
+}
87
+
88
+const props = withDefaults(defineProps<Props>(), {
89
+  currentPath: '/',
90
+})
91
+
92
+const emit = defineEmits<{
93
+  close: []
94
+  created: [path: string]
95
+}>()
96
+
97
+// Form state
98
+const form = ref({
99
+  name: '',
100
+  encrypted: false,
101
+})
102
+
103
+const nameInput = ref<HTMLInputElement>()
104
+const loading = ref(false)
105
+const error = ref('')
106
+const errors = ref<Record<string, string>>({})
107
+
108
+// Computed
109
+const displayPath = computed(() => {
110
+  return props.currentPath === '/' ? '/' : props.currentPath
111
+})
112
+
113
+const isValid = computed(() => {
114
+  return form.value.name.trim().length > 0 && !errors.value.name
115
+})
116
+
117
+// Methods
118
+function validateName() {
119
+  errors.value.name = ''
120
+
121
+  const name = form.value.name.trim()
122
+
123
+  if (!name) {
124
+    errors.value.name = 'Folder name is required'
125
+    return
126
+  }
127
+
128
+  if (name.length > 255) {
129
+    errors.value.name = 'Folder name must be less than 255 characters'
130
+    return
131
+  }
132
+
133
+  // Check for invalid characters
134
+  const invalidChars = /[<>:"/\\|?*\x00-\x1f]/
135
+  if (invalidChars.test(name)) {
136
+    errors.value.name = 'Folder name contains invalid characters'
137
+    return
138
+  }
139
+
140
+  // Check for reserved names
141
+  const reservedNames = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9']
142
+  if (reservedNames.includes(name.toUpperCase())) {
143
+    errors.value.name = 'This folder name is reserved'
144
+    return
145
+  }
146
+
147
+  // Check for names starting/ending with spaces or dots
148
+  if (name.startsWith(' ') || name.endsWith(' ') || name.startsWith('.') || name.endsWith('.')) {
149
+    errors.value.name = 'Folder name cannot start or end with spaces or dots'
150
+    return
151
+  }
152
+}
153
+
154
+async function handleCreate() {
155
+  if (!isValid.value || loading.value) return
156
+
157
+  loading.value = true
158
+  error.value = ''
159
+
160
+  try {
161
+    const folderName = form.value.name.trim()
162
+
163
+    // Create folder via API
164
+    await apiClient.request({
165
+      method: 'POST',
166
+      url: '/files/folder',
167
+      data: {
168
+        name: folderName,
169
+        path: props.currentPath,
170
+        encrypted: form.value.encrypted,
171
+      },
172
+    })
173
+
174
+    const newPath = props.currentPath === '/' ? `/${folderName}` : `${props.currentPath}/${folderName}`
175
+
176
+    emit('created', newPath)
177
+    emit('close')
178
+
179
+    // Show success notification
180
+    if (window.$notify) {
181
+      window.$notify.success(`Folder "${folderName}" created successfully`)
182
+    }
183
+  } catch (err: any) {
184
+    error.value = err.response?.data?.message || 'Failed to create folder'
185
+    console.error('Create folder failed:', err)
186
+  } finally {
187
+    loading.value = false
188
+  }
189
+}
190
+
191
+// Focus input on mount
192
+onMounted(async () => {
193
+  await nextTick()
194
+  nameInput.value?.focus()
195
+})
196
+</script>
client/src/components/FileBrowser.vuemodified
@@ -1,5 +1,13 @@
1
 <template>
1
 <template>
2
   <div class="file-browser">
2
   <div class="file-browser">
3
+    <!-- Create folder modal -->
4
+    <CreateFolderModal
5
+      v-if="showCreateFolder"
6
+      :current-path="currentPath"
7
+      @close="showCreateFolder = false"
8
+      @created="handleFolderCreated"
9
+    />
10
+
3
     <!-- Toolbar -->
11
     <!-- Toolbar -->
4
     <div class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-4">
12
     <div class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-4">
5
       <div class="flex items-center justify-between">
13
       <div class="flex items-center justify-between">
@@ -80,14 +88,26 @@
80
             </MenuItems>
88
             </MenuItems>
81
           </Menu>
89
           </Menu>
82
 
90
 
83
-          <!-- Upload button -->
91
+          <!-- Actions -->
84
-          <button
92
+          <div class="flex items-center space-x-2">
85
-            @click="$emit('upload')"
93
+            <!-- Create folder button -->
86
-            class="btn btn-primary"
94
+            <button
87
-          >
95
+              @click="showCreateFolder = true"
88
-            <ArrowUpTrayIcon class="w-4 h-4 mr-2" />
96
+              class="btn btn-outline"
89
-            Upload
97
+            >
90
-          </button>
98
+              <FolderPlusIcon class="w-4 h-4 mr-2" />
99
+              New Folder
100
+            </button>
101
+
102
+            <!-- Upload button -->
103
+            <button
104
+              @click="$emit('upload')"
105
+              class="btn btn-primary"
106
+            >
107
+              <ArrowUpTrayIcon class="w-4 h-4 mr-2" />
108
+              Upload
109
+            </button>
110
+          </div>
91
         </div>
111
         </div>
92
       </div>
112
       </div>
93
 
113
 
@@ -200,9 +220,11 @@ import {
200
   TrashIcon,
220
   TrashIcon,
201
   ExclamationTriangleIcon,
221
   ExclamationTriangleIcon,
202
   FolderIcon,
222
   FolderIcon,
223
+  FolderPlusIcon,
203
 } from '@heroicons/vue/24/outline'
224
 } from '@heroicons/vue/24/outline'
204
 import FileCard from './FileCard.vue'
225
 import FileCard from './FileCard.vue'
205
 import FileRow from './FileRow.vue'
226
 import FileRow from './FileRow.vue'
227
+import CreateFolderModal from './CreateFolderModal.vue'
206
 import type { FileItem } from '@shared/types'
228
 import type { FileItem } from '@shared/types'
207
 
229
 
208
 // Emits
230
 // Emits
@@ -215,6 +237,9 @@ const filesStore = useFilesStore()
215
 const route = useRoute()
237
 const route = useRoute()
216
 const router = useRouter()
238
 const router = useRouter()
217
 
239
 
240
+// State
241
+const showCreateFolder = ref(false)
242
+
218
 // Computed
243
 // Computed
219
 const { currentListing, currentPath, loading, error, selectedFiles, viewMode, sortBy, sortOrder } = filesStore
244
 const { currentListing, currentPath, loading, error, selectedFiles, viewMode, sortBy, sortOrder } = filesStore
220
 const { sortedFiles, hasSelection } = storeToRefs(filesStore)
245
 const { sortedFiles, hasSelection } = storeToRefs(filesStore)
@@ -265,8 +290,52 @@ async function deleteFile(fileId: string) {
265
 }
290
 }
266
 
291
 
267
 async function downloadSelected() {
292
 async function downloadSelected() {
268
-  // TODO: Implement bulk download
293
+  if (!hasSelection.value) return
269
-  console.log('Bulk download not implemented yet')
294
+
295
+  try {
296
+    const fileIds = Array.from(selectedFiles.value)
297
+
298
+    // Call bulk download API
299
+    const response = await fetch('/api/files/bulk/download', {
300
+      method: 'POST',
301
+      headers: {
302
+        'Content-Type': 'application/json',
303
+        'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
304
+      },
305
+      body: JSON.stringify({
306
+        fileIds,
307
+        format: 'zip',
308
+        archiveName: `zephyrfs-files-${new Date().toISOString().split('T')[0]}.zip`,
309
+      }),
310
+    })
311
+
312
+    if (!response.ok) {
313
+      throw new Error('Bulk download failed')
314
+    }
315
+
316
+    // Create download link
317
+    const blob = await response.blob()
318
+    const url = window.URL.createObjectURL(blob)
319
+    const link = document.createElement('a')
320
+    link.href = url
321
+    link.download = `zephyrfs-files-${new Date().toISOString().split('T')[0]}.zip`
322
+    document.body.appendChild(link)
323
+    link.click()
324
+    document.body.removeChild(link)
325
+    window.URL.revokeObjectURL(url)
326
+
327
+    // Clear selection
328
+    filesStore.clearSelection()
329
+
330
+    if (window.$notify) {
331
+      window.$notify.success(`Downloaded ${fileIds.length} files as ZIP archive`)
332
+    }
333
+  } catch (error) {
334
+    console.error('Bulk download failed:', error)
335
+    if (window.$notify) {
336
+      window.$notify.error('Failed to download selected files')
337
+    }
338
+  }
270
 }
339
 }
271
 
340
 
272
 async function deleteSelected() {
341
 async function deleteSelected() {
@@ -283,6 +352,12 @@ async function loadDirectory(path: string) {
283
   await filesStore.loadDirectory(path)
352
   await filesStore.loadDirectory(path)
284
 }
353
 }
285
 
354
 
355
+function handleFolderCreated(folderPath: string) {
356
+  showCreateFolder.value = false
357
+  // Refresh current directory to show new folder
358
+  loadDirectory(currentPath.value)
359
+}
360
+
286
 // Watch route changes
361
 // Watch route changes
287
 watch(
362
 watch(
288
   () => route.params.path,
363
   () => route.params.path,
client/src/components/FileCard.vueadded
@@ -0,0 +1,266 @@
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>
client/src/components/FilePreview.vueadded
@@ -0,0 +1,378 @@
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>
client/src/components/FileRow.vueadded
@@ -0,0 +1,295 @@
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>
client/src/components/JsonViewer.vueadded
@@ -0,0 +1,164 @@
1
+<template>
2
+  <div class="json-viewer">
3
+    <div class="json-content text-sm font-mono">
4
+      <JsonNode
5
+        :data="data"
6
+        :key-name="''"
7
+        :level="0"
8
+        :is-root="true"
9
+      />
10
+    </div>
11
+  </div>
12
+</template>
13
+
14
+<script setup lang="ts">
15
+import { defineComponent } from 'vue'
16
+
17
+interface Props {
18
+  data: any
19
+}
20
+
21
+defineProps<Props>()
22
+
23
+// Recursive JSON node component
24
+const JsonNode = defineComponent({
25
+  name: 'JsonNode',
26
+  props: {
27
+    data: { required: true },
28
+    keyName: { type: String, default: '' },
29
+    level: { type: Number, default: 0 },
30
+    isRoot: { type: Boolean, default: false },
31
+  },
32
+  data() {
33
+    return {
34
+      collapsed: this.level > 2, // Auto-collapse deep objects
35
+    }
36
+  },
37
+  computed: {
38
+    dataType() {
39
+      if (this.data === null) return 'null'
40
+      if (Array.isArray(this.data)) return 'array'
41
+      return typeof this.data
42
+    },
43
+    isCollapsible() {
44
+      return this.dataType === 'object' || this.dataType === 'array'
45
+    },
46
+    keys() {
47
+      if (this.dataType === 'object') {
48
+        return Object.keys(this.data)
49
+      }
50
+      if (this.dataType === 'array') {
51
+        return this.data.map((_: any, index: number) => index.toString())
52
+      }
53
+      return []
54
+    },
55
+    hasChildren() {
56
+      return this.keys.length > 0
57
+    },
58
+    indentStyle() {
59
+      return {
60
+        paddingLeft: `${this.level * 20}px`,
61
+      }
62
+    },
63
+  },
64
+  methods: {
65
+    toggleCollapse() {
66
+      if (this.isCollapsible) {
67
+        this.collapsed = !this.collapsed
68
+      }
69
+    },
70
+    formatValue(value: any) {
71
+      if (value === null) return 'null'
72
+      if (typeof value === 'string') return `"${value}"`
73
+      if (typeof value === 'boolean') return value.toString()
74
+      if (typeof value === 'number') return value.toString()
75
+      return String(value)
76
+    },
77
+    getValueClass(value: any) {
78
+      if (value === null) return 'text-gray-500'
79
+      if (typeof value === 'string') return 'text-green-600 dark:text-green-400'
80
+      if (typeof value === 'boolean') return 'text-blue-600 dark:text-blue-400'
81
+      if (typeof value === 'number') return 'text-purple-600 dark:text-purple-400'
82
+      return 'text-gray-700 dark:text-gray-300'
83
+    },
84
+  },
85
+  template: `
86
+    <div class="json-node">
87
+      <div
88
+        v-if="!isRoot"
89
+        class="json-line flex items-start hover:bg-gray-100 dark:hover:bg-gray-700 rounded px-1"
90
+        :style="indentStyle"
91
+      >
92
+        <!-- Toggle button -->
93
+        <button
94
+          v-if="isCollapsible"
95
+          @click="toggleCollapse"
96
+          class="flex-shrink-0 w-4 h-4 mt-0.5 mr-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
97
+        >
98
+          <svg class="w-3 h-3 transition-transform" :class="{ 'rotate-90': !collapsed }" fill="currentColor" viewBox="0 0 20 20">
99
+            <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 111.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
100
+          </svg>
101
+        </button>
102
+        <span v-else class="w-4 mr-1"></span>
103
+
104
+        <!-- Key name -->
105
+        <span v-if="keyName" class="text-blue-700 dark:text-blue-300 mr-2">
106
+          "{{ keyName }}":
107
+        </span>
108
+
109
+        <!-- Value for primitives -->
110
+        <span
111
+          v-if="!isCollapsible"
112
+          :class="getValueClass(data)"
113
+        >
114
+          {{ formatValue(data) }}
115
+        </span>
116
+
117
+        <!-- Container indicators -->
118
+        <span v-else class="text-gray-600 dark:text-gray-400">
119
+          <span v-if="dataType === 'array'">
120
+            [{{ collapsed ? \`\${keys.length} items\` : '' }}
121
+          </span>
122
+          <span v-else-if="dataType === 'object'">
123
+            {{{ collapsed ? \`\${keys.length} keys\` : '' }}
124
+          </span>
125
+        </span>
126
+      </div>
127
+
128
+      <!-- Children -->
129
+      <div v-if="(isRoot || !collapsed) && hasChildren">
130
+        <JsonNode
131
+          v-for="key in keys"
132
+          :key="key"
133
+          :data="data[key]"
134
+          :key-name="key"
135
+          :level="isRoot ? 0 : level + 1"
136
+        />
137
+      </div>
138
+
139
+      <!-- Closing brackets -->
140
+      <div
141
+        v-if="(isRoot || !collapsed) && isCollapsible && !isRoot"
142
+        class="text-gray-600 dark:text-gray-400 px-1"
143
+        :style="indentStyle"
144
+      >
145
+        {{ dataType === 'array' ? ']' : '}' }}
146
+      </div>
147
+    </div>
148
+  `,
149
+})
150
+</script>
151
+
152
+<style scoped>
153
+.json-viewer {
154
+  @apply text-sm;
155
+}
156
+
157
+.json-line {
158
+  @apply py-0.5;
159
+}
160
+
161
+.json-content {
162
+  @apply leading-relaxed;
163
+}
164
+</style>
server/package.jsonmodified
@@ -20,11 +20,13 @@
20
     "@fastify/websocket": "^8.3.1",
20
     "@fastify/websocket": "^8.3.1",
21
     "webdav": "^5.3.0",
21
     "webdav": "^5.3.0",
22
     "ws": "^8.14.2",
22
     "ws": "^8.14.2",
23
-    "zod": "^3.22.4"
23
+    "zod": "^3.22.4",
24
+    "archiver": "^6.0.1"
24
   },
25
   },
25
   "devDependencies": {
26
   "devDependencies": {
26
     "@types/node": "^20.8.10",
27
     "@types/node": "^20.8.10",
27
     "@types/ws": "^8.5.8",
28
     "@types/ws": "^8.5.8",
29
+    "@types/archiver": "^6.0.2",
28
     "@typescript-eslint/eslint-plugin": "^6.9.1",
30
     "@typescript-eslint/eslint-plugin": "^6.9.1",
29
     "@typescript-eslint/parser": "^6.9.1",
31
     "@typescript-eslint/parser": "^6.9.1",
30
     "eslint": "^8.52.0",
32
     "eslint": "^8.52.0",
server/src/routes/bulk.tsadded
@@ -0,0 +1,184 @@
1
+import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2
+import { z } from 'zod';
3
+import archiver from 'archiver';
4
+import { PassThrough } from 'node:stream';
5
+
6
+const bulkDownloadSchema = z.object({
7
+  fileIds: z.array(z.string()).min(1).max(100), // Limit to 100 files
8
+  format: z.enum(['zip', 'tar']).default('zip'),
9
+  archiveName: z.string().optional(),
10
+});
11
+
12
+const bulkDeleteSchema = z.object({
13
+  fileIds: z.array(z.string()).min(1).max(100),
14
+});
15
+
16
+const bulkMoveSchema = z.object({
17
+  fileIds: z.array(z.string()).min(1).max(100),
18
+  targetPath: z.string(),
19
+});
20
+
21
+export async function bulkRoutes(fastify: FastifyInstance) {
22
+  // Bulk download files as archive
23
+  fastify.post<{
24
+    Body: z.infer<typeof bulkDownloadSchema>;
25
+  }>('/files/bulk/download', {
26
+    schema: {
27
+      body: bulkDownloadSchema,
28
+    },
29
+    preHandler: fastify.authenticate,
30
+  }, async (request: FastifyRequest, reply: FastifyReply) => {
31
+    const { fileIds, format, archiveName } = request.body as z.infer<typeof bulkDownloadSchema>;
32
+
33
+    try {
34
+      // Create archive
35
+      const archive = archiver(format, {
36
+        zlib: { level: 6 }, // Compression level for zip
37
+      });
38
+
39
+      const passThrough = new PassThrough();
40
+      archive.pipe(passThrough);
41
+
42
+      // Set response headers
43
+      const timestamp = new Date().toISOString().split('T')[0];
44
+      const defaultName = archiveName || `zephyrfs-files-${timestamp}.${format}`;
45
+
46
+      reply.header('Content-Type', `application/${format}`);
47
+      reply.header('Content-Disposition', `attachment; filename="${defaultName}"`);
48
+
49
+      // Handle archive errors
50
+      archive.on('error', (err) => {
51
+        fastify.log.error(err, 'Archive creation failed');
52
+        if (!reply.sent) {
53
+          reply.code(500).send({ error: 'Archive creation failed' });
54
+        }
55
+      });
56
+
57
+      // Process each file
58
+      const filePromises = fileIds.map(async (fileId) => {
59
+        try {
60
+          // Get file info first
61
+          const fileInfo = await fastify.zephyrfs.getFileInfo(fileId);
62
+
63
+          // Download file stream
64
+          const download = await fastify.zephyrfs.downloadFile(fileId);
65
+
66
+          // Add to archive
67
+          archive.append(download.stream as any, {
68
+            name: fileInfo.name,
69
+            date: fileInfo.lastModified,
70
+          });
71
+
72
+        } catch (error) {
73
+          fastify.log.warn({ fileId, error }, 'Failed to add file to archive');
74
+          // Continue with other files instead of failing entire operation
75
+        }
76
+      });
77
+
78
+      // Wait for all files to be processed
79
+      await Promise.allSettled(filePromises);
80
+
81
+      // Finalize archive
82
+      await archive.finalize();
83
+
84
+      // Stream the archive
85
+      return reply.send(passThrough);
86
+
87
+    } catch (error) {
88
+      fastify.log.error(error, 'Bulk download failed');
89
+      throw fastify.httpErrors.internalServerError('Bulk download failed');
90
+    }
91
+  });
92
+
93
+  // Bulk delete files
94
+  fastify.delete<{
95
+    Body: z.infer<typeof bulkDeleteSchema>;
96
+  }>('/files/bulk/delete', {
97
+    schema: {
98
+      body: bulkDeleteSchema,
99
+    },
100
+    preHandler: fastify.authenticate,
101
+  }, async (request: FastifyRequest, reply: FastifyReply) => {
102
+    const { fileIds } = request.body as z.infer<typeof bulkDeleteSchema>;
103
+
104
+    try {
105
+      const results = await Promise.allSettled(
106
+        fileIds.map(fileId => fastify.zephyrfs.deleteFile(fileId))
107
+      );
108
+
109
+      // Count successes and failures
110
+      const successful = results.filter(r => r.status === 'fulfilled').length;
111
+      const failed = results.filter(r => r.status === 'rejected').length;
112
+
113
+      // Get details of failures
114
+      const failures = results
115
+        .map((result, index) => ({ result, fileId: fileIds[index] }))
116
+        .filter(({ result }) => result.status === 'rejected')
117
+        .map(({ result, fileId }) => ({
118
+          fileId,
119
+          error: (result as PromiseRejectedResult).reason?.message || 'Unknown error'
120
+        }));
121
+
122
+      return {
123
+        success: true,
124
+        summary: {
125
+          total: fileIds.length,
126
+          successful,
127
+          failed,
128
+        },
129
+        failures: failed > 0 ? failures : undefined,
130
+      };
131
+
132
+    } catch (error) {
133
+      fastify.log.error(error, 'Bulk delete failed');
134
+      throw fastify.httpErrors.internalServerError('Bulk delete failed');
135
+    }
136
+  });
137
+
138
+  // Bulk move files
139
+  fastify.post<{
140
+    Body: z.infer<typeof bulkMoveSchema>;
141
+  }>('/files/bulk/move', {
142
+    schema: {
143
+      body: bulkMoveSchema,
144
+    },
145
+    preHandler: fastify.authenticate,
146
+  }, async (request: FastifyRequest, reply: FastifyReply) => {
147
+    const { fileIds, targetPath } = request.body as z.infer<typeof bulkMoveSchema>;
148
+
149
+    try {
150
+      // For now, return not implemented since file moving requires
151
+      // proper implementation in the ZephyrFS core
152
+      throw fastify.httpErrors.notImplemented('Bulk move is not yet implemented');
153
+
154
+    } catch (error) {
155
+      fastify.log.error(error, 'Bulk move failed');
156
+      if (error.statusCode) {
157
+        throw error;
158
+      }
159
+      throw fastify.httpErrors.internalServerError('Bulk move failed');
160
+    }
161
+  });
162
+
163
+  // Get bulk operation status
164
+  fastify.get<{
165
+    Params: { operationId: string };
166
+  }>('/files/bulk/status/:operationId', {
167
+    preHandler: fastify.authenticate,
168
+  }, async (request: FastifyRequest, reply: FastifyReply) => {
169
+    const { operationId } = request.params as { operationId: string };
170
+
171
+    try {
172
+      // For now, return not implemented since this would require
173
+      // background job tracking
174
+      throw fastify.httpErrors.notImplemented('Bulk operation status tracking is not yet implemented');
175
+
176
+    } catch (error) {
177
+      fastify.log.error(error, 'Failed to get bulk operation status');
178
+      if (error.statusCode) {
179
+        throw error;
180
+      }
181
+      throw fastify.httpErrors.internalServerError('Failed to get bulk operation status');
182
+    }
183
+  });
184
+}
server/src/routes/folders.tsadded
@@ -0,0 +1,211 @@
1
+import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2
+import { z } from 'zod';
3
+
4
+const createFolderSchema = z.object({
5
+  name: z.string().min(1).max(255),
6
+  path: z.string().default('/'),
7
+  encrypted: z.boolean().optional().default(false),
8
+});
9
+
10
+const renameFolderSchema = z.object({
11
+  newName: z.string().min(1).max(255),
12
+});
13
+
14
+const moveFolderSchema = z.object({
15
+  sourcePath: z.string(),
16
+  targetPath: z.string(),
17
+});
18
+
19
+export async function foldersRoutes(fastify: FastifyInstance) {
20
+  // Create folder
21
+  fastify.post<{
22
+    Body: z.infer<typeof createFolderSchema>;
23
+  }>('/files/folder', {
24
+    schema: {
25
+      body: createFolderSchema,
26
+    },
27
+    preHandler: fastify.authenticate,
28
+  }, async (request: FastifyRequest, reply: FastifyReply) => {
29
+    const { name, path, encrypted } = request.body as z.infer<typeof createFolderSchema>;
30
+
31
+    try {
32
+      // Validate folder name
33
+      const invalidChars = /[<>:"/\\|?*\x00-\x1f]/;
34
+      if (invalidChars.test(name)) {
35
+        throw fastify.httpErrors.badRequest('Folder name contains invalid characters');
36
+      }
37
+
38
+      // Check for reserved names
39
+      const reservedNames = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'];
40
+      if (reservedNames.includes(name.toUpperCase())) {
41
+        throw fastify.httpErrors.badRequest('This folder name is reserved');
42
+      }
43
+
44
+      // Trim and validate
45
+      const folderName = name.trim();
46
+      if (!folderName || folderName.startsWith('.') || folderName.endsWith('.')) {
47
+        throw fastify.httpErrors.badRequest('Invalid folder name');
48
+      }
49
+
50
+      // Check if folder already exists
51
+      try {
52
+        const listing = await fastify.zephyrfs.listFiles(path);
53
+        const existingFolder = listing.files.find(f => f.name === folderName && f.type === 'directory');
54
+        if (existingFolder) {
55
+          throw fastify.httpErrors.conflict('Folder already exists');
56
+        }
57
+      } catch (error) {
58
+        // If listing fails, the parent directory doesn't exist
59
+        if (error.statusCode === 404) {
60
+          throw fastify.httpErrors.notFound('Parent directory does not exist');
61
+        }
62
+        throw error;
63
+      }
64
+
65
+      // Create folder by creating a marker file (since most storage systems don't support empty directories)
66
+      const folderPath = path === '/' ? `/${folderName}` : `${path}/${folderName}`;
67
+      const markerFileName = '.zephyrfs_folder_marker';
68
+      const markerContent = JSON.stringify({
69
+        type: 'directory',
70
+        name: folderName,
71
+        created: new Date().toISOString(),
72
+        encrypted: encrypted,
73
+      });
74
+
75
+      const markerBuffer = Buffer.from(markerContent, 'utf-8');
76
+      await fastify.zephyrfs.uploadFile(folderPath, markerFileName, markerBuffer, {
77
+        encrypted: false, // Marker files are always unencrypted for metadata
78
+      });
79
+
80
+      return {
81
+        success: true,
82
+        path: folderPath,
83
+        name: folderName,
84
+        encrypted: encrypted,
85
+      };
86
+    } catch (error) {
87
+      fastify.log.error(error, 'Failed to create folder');
88
+      if (error.statusCode) {
89
+        throw error;
90
+      }
91
+      throw fastify.httpErrors.internalServerError('Failed to create folder');
92
+    }
93
+  });
94
+
95
+  // Rename folder
96
+  fastify.patch<{
97
+    Params: { path: string };
98
+    Body: z.infer<typeof renameFolderSchema>;
99
+  }>('/files/folder/:path(*)', {
100
+    schema: {
101
+      body: renameFolderSchema,
102
+    },
103
+    preHandler: fastify.authenticate,
104
+  }, async (request: FastifyRequest, reply: FastifyReply) => {
105
+    const folderPath = '/' + (request.params as any).path;
106
+    const { newName } = request.body as z.infer<typeof renameFolderSchema>;
107
+
108
+    try {
109
+      // Validate new name
110
+      const invalidChars = /[<>:"/\\|?*\x00-\x1f]/;
111
+      if (invalidChars.test(newName)) {
112
+        throw fastify.httpErrors.badRequest('Folder name contains invalid characters');
113
+      }
114
+
115
+      const trimmedName = newName.trim();
116
+      if (!trimmedName || trimmedName.startsWith('.') || trimmedName.endsWith('.')) {
117
+        throw fastify.httpErrors.badRequest('Invalid folder name');
118
+      }
119
+
120
+      // Get parent path
121
+      const pathParts = folderPath.split('/').filter(Boolean);
122
+      const parentPath = pathParts.length > 1 ? '/' + pathParts.slice(0, -1).join('/') : '/';
123
+
124
+      // Check if target name already exists
125
+      try {
126
+        const parentListing = await fastify.zephyrfs.listFiles(parentPath);
127
+        const existingItem = parentListing.files.find(f => f.name === trimmedName);
128
+        if (existingItem) {
129
+          throw fastify.httpErrors.conflict('An item with this name already exists');
130
+        }
131
+      } catch (error) {
132
+        if (error.statusCode === 404) {
133
+          throw fastify.httpErrors.notFound('Parent directory does not exist');
134
+        }
135
+        throw error;
136
+      }
137
+
138
+      // For now, return not implemented since folder renaming requires moving all contained files
139
+      // This would need to be implemented in the ZephyrFS core with proper atomic operations
140
+      throw fastify.httpErrors.notImplemented('Folder renaming is not yet implemented');
141
+
142
+    } catch (error) {
143
+      fastify.log.error(error, 'Failed to rename folder');
144
+      if (error.statusCode) {
145
+        throw error;
146
+      }
147
+      throw fastify.httpErrors.internalServerError('Failed to rename folder');
148
+    }
149
+  });
150
+
151
+  // Move folder
152
+  fastify.post<{
153
+    Body: z.infer<typeof moveFolderSchema>;
154
+  }>('/files/folder/move', {
155
+    schema: {
156
+      body: moveFolderSchema,
157
+    },
158
+    preHandler: fastify.authenticate,
159
+  }, async (request: FastifyRequest, reply: FastifyReply) => {
160
+    const { sourcePath, targetPath } = request.body as z.infer<typeof moveFolderSchema>;
161
+
162
+    try {
163
+      // For now, return not implemented since folder moving requires atomic operations
164
+      // This would need to be implemented in the ZephyrFS core
165
+      throw fastify.httpErrors.notImplemented('Folder moving is not yet implemented');
166
+
167
+    } catch (error) {
168
+      fastify.log.error(error, 'Failed to move folder');
169
+      if (error.statusCode) {
170
+        throw error;
171
+      }
172
+      throw fastify.httpErrors.internalServerError('Failed to move folder');
173
+    }
174
+  });
175
+
176
+  // Delete empty folder
177
+  fastify.delete<{
178
+    Params: { path: string };
179
+  }>('/files/folder/:path(*)', {
180
+    preHandler: fastify.authenticate,
181
+  }, async (request: FastifyRequest, reply: FastifyReply) => {
182
+    const folderPath = '/' + (request.params as any).path;
183
+
184
+    try {
185
+      // Check if folder exists and is empty
186
+      const listing = await fastify.zephyrfs.listFiles(folderPath);
187
+
188
+      // Filter out the marker file
189
+      const realFiles = listing.files.filter(f => f.name !== '.zephyrfs_folder_marker');
190
+
191
+      if (realFiles.length > 0) {
192
+        throw fastify.httpErrors.badRequest('Folder is not empty');
193
+      }
194
+
195
+      // Delete the marker file
196
+      const markerFile = listing.files.find(f => f.name === '.zephyrfs_folder_marker');
197
+      if (markerFile) {
198
+        await fastify.zephyrfs.deleteFile(markerFile.id);
199
+      }
200
+
201
+      return { success: true };
202
+
203
+    } catch (error) {
204
+      fastify.log.error(error, 'Failed to delete folder');
205
+      if (error.statusCode) {
206
+        throw error;
207
+      }
208
+      throw fastify.httpErrors.internalServerError('Failed to delete folder');
209
+    }
210
+  });
211
+}
server/src/routes/index.tsmodified
@@ -3,11 +3,15 @@ import { filesRoutes } from './files.js';
3
 import { statusRoutes } from './status.js';
3
 import { statusRoutes } from './status.js';
4
 import { authRoutes } from './auth.js';
4
 import { authRoutes } from './auth.js';
5
 import { webdavRoutes } from './webdav.js';
5
 import { webdavRoutes } from './webdav.js';
6
+import { foldersRoutes } from './folders.js';
7
+import { bulkRoutes } from './bulk.js';
6
 
8
 
7
 export async function registerRoutes(fastify: FastifyInstance) {
9
 export async function registerRoutes(fastify: FastifyInstance) {
8
   // Register all route modules
10
   // Register all route modules
9
   await fastify.register(authRoutes);
11
   await fastify.register(authRoutes);
10
   await fastify.register(filesRoutes);
12
   await fastify.register(filesRoutes);
13
+  await fastify.register(foldersRoutes);
14
+  await fastify.register(bulkRoutes);
11
   await fastify.register(statusRoutes);
15
   await fastify.register(statusRoutes);
12
   await fastify.register(webdavRoutes);
16
   await fastify.register(webdavRoutes);
13
 
17