summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--frontend/app/api/directories/route.ts16
-rw-r--r--frontend/app/v2/[...path]/page.tsx12
-rw-r--r--frontend/app/v2/page.tsx5
-rw-r--r--frontend/components/ui/scroll-area.tsx48
-rw-r--r--frontend/components/v2/V2DirectoryView.tsx694
-rw-r--r--frontend/components/v2/V2MoveDialog.tsx169
-rw-r--r--frontend/lib/drive_server.ts7
-rw-r--r--frontend/package-lock.json38
-rw-r--r--frontend/package.json1
9 files changed, 990 insertions, 0 deletions
diff --git a/frontend/app/api/directories/route.ts b/frontend/app/api/directories/route.ts
new file mode 100644
index 0000000..b3515bb
--- /dev/null
+++ b/frontend/app/api/directories/route.ts
@@ -0,0 +1,16 @@
1import { NextResponse } from 'next/server'
2import { Drive_ls_directories } from '@/lib/drive_server'
3
4export async function GET() {
5 try {
6 const directories = await Drive_ls_directories()
7
8 return NextResponse.json(directories)
9 } catch (error) {
10 console.error('Error fetching directories:', error)
11 return NextResponse.json(
12 { error: error instanceof Error ? error.message : 'Internal server error' },
13 { status: 500 }
14 )
15 }
16} \ No newline at end of file
diff --git a/frontend/app/v2/[...path]/page.tsx b/frontend/app/v2/[...path]/page.tsx
new file mode 100644
index 0000000..4af0167
--- /dev/null
+++ b/frontend/app/v2/[...path]/page.tsx
@@ -0,0 +1,12 @@
1import { V2DirectoryView } from "@/components/v2/V2DirectoryView"
2
3export default async function V2DirectoryPage({
4 params,
5}: {
6 params: Promise<{ path: string[] }>
7}) {
8 const { path: pathSegments } = await params
9 const currentPath = '/' + (pathSegments?.join('/') || '')
10
11 return <V2DirectoryView path={currentPath} />
12} \ No newline at end of file
diff --git a/frontend/app/v2/page.tsx b/frontend/app/v2/page.tsx
new file mode 100644
index 0000000..e693c77
--- /dev/null
+++ b/frontend/app/v2/page.tsx
@@ -0,0 +1,5 @@
1import { V2DirectoryView } from "@/components/v2/V2DirectoryView"
2
3export default function V2RootPage() {
4 return <V2DirectoryView path="/" />
5} \ No newline at end of file
diff --git a/frontend/components/ui/scroll-area.tsx b/frontend/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..63b95e3
--- /dev/null
+++ b/frontend/components/ui/scroll-area.tsx
@@ -0,0 +1,48 @@
1"use client"
2
3import * as React from "react"
4import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
5
6import { cn } from "@/lib/utils"
7
8const ScrollArea = React.forwardRef<
9 React.ElementRef<typeof ScrollAreaPrimitive.Root>,
10 React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
11>(({ className, children, ...props }, ref) => (
12 <ScrollAreaPrimitive.Root
13 ref={ref}
14 className={cn("relative overflow-hidden", className)}
15 {...props}
16 >
17 <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
18 {children}
19 </ScrollAreaPrimitive.Viewport>
20 <ScrollBar />
21 <ScrollAreaPrimitive.Corner />
22 </ScrollAreaPrimitive.Root>
23))
24ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
25
26const ScrollBar = React.forwardRef<
27 React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
28 React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
29>(({ className, orientation = "vertical", ...props }, ref) => (
30 <ScrollAreaPrimitive.ScrollAreaScrollbar
31 ref={ref}
32 orientation={orientation}
33 className={cn(
34 "flex touch-none select-none transition-colors",
35 orientation === "vertical" &&
36 "h-full w-2.5 border-l border-l-transparent p-[1px]",
37 orientation === "horizontal" &&
38 "h-2.5 flex-col border-t border-t-transparent p-[1px]",
39 className
40 )}
41 {...props}
42 >
43 <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
44 </ScrollAreaPrimitive.ScrollAreaScrollbar>
45))
46ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
47
48export { ScrollArea, ScrollBar } \ No newline at end of file
diff --git a/frontend/components/v2/V2DirectoryView.tsx b/frontend/components/v2/V2DirectoryView.tsx
new file mode 100644
index 0000000..03532e8
--- /dev/null
+++ b/frontend/components/v2/V2DirectoryView.tsx
@@ -0,0 +1,694 @@
1"use client"
2
3import type React from "react"
4import { useState, useRef, useEffect } from "react"
5import Link from "next/link"
6import {
7 ChevronRight,
8 File,
9 Folder,
10 Upload,
11 Trash2,
12 Move,
13 MoreHorizontal,
14 HardDrive,
15 Edit,
16 Link as LinkIcon,
17 Info,
18 LogIn,
19 LogOut,
20 Home,
21} from "lucide-react"
22import { Button } from "@/components/ui/button"
23import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
24import { Progress } from "@/components/ui/progress"
25import {
26 DropdownMenu,
27 DropdownMenuContent,
28 DropdownMenuItem,
29 DropdownMenuTrigger,
30 DropdownMenuSeparator,
31} from "@/components/ui/dropdown-menu"
32import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
33import { Input } from "@/components/ui/input"
34import { Label } from "@/components/ui/label"
35import { Checkbox } from "@/components/ui/checkbox"
36import { toast } from "@/hooks/use-toast"
37import { DriveLsEntry } from "@/lib/drive_types"
38import { UPLOAD_MAX_FILE_SIZE, UPLOAD_MAX_FILES } from "@/lib/constants"
39import { V2MoveDialog } from "./V2MoveDialog"
40
41function formatFileSize(bytes: number): string {
42 if (bytes === 0) return "0 Bytes"
43 const k = 1024
44 const sizes = ["Bytes", "KB", "MB", "GB"]
45 const i = Math.floor(Math.log(bytes) / Math.log(k))
46 return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
47}
48
49function formatDate(timestamp: number): string {
50 return new Date(timestamp * 1000).toISOString().split('T')[0]
51}
52
53function formatDateTime(timestamp: number): string {
54 const date = new Date(timestamp * 1000)
55 const dateStr = date.toISOString().split('T')[0]
56 const timeStr = date.toLocaleTimeString('en-US', {
57 hour12: false,
58 hour: '2-digit',
59 minute: '2-digit',
60 second: '2-digit'
61 })
62 return `${dateStr} at ${timeStr}`
63}
64
65interface V2DirectoryViewProps {
66 path: string
67}
68
69export function V2DirectoryView({ path }: V2DirectoryViewProps) {
70 const [files, setFiles] = useState<DriveLsEntry[]>([])
71 const [loading, setLoading] = useState(true)
72 const [error, setError] = useState<string | null>(null)
73 const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set())
74 const [renameDialogOpen, setRenameDialogOpen] = useState(false)
75 const [infoDialogOpen, setInfoDialogOpen] = useState(false)
76 const [moveDialogOpen, setMoveDialogOpen] = useState(false)
77 const [currentItem, setCurrentItem] = useState<DriveLsEntry | null>(null)
78 const [newName, setNewName] = useState("")
79 const fileInputRef = useRef<HTMLInputElement>(null)
80 const [uploading, setUploading] = useState(false)
81
82 const [isLoggedIn, setIsLoggedIn] = useState(true) // Mock logged in state
83
84 const maxStorage = 1073741824 // 1GB
85 const usedStorage = 0 // TODO: Calculate from files if needed
86 const storagePercentage = (usedStorage / maxStorage) * 100
87
88 // Generate breadcrumbs from path
89 const generateBreadcrumbs = (currentPath: string) => {
90 if (currentPath === '/') {
91 return [{ name: 'Root', path: '/v2' }]
92 }
93
94 const parts = currentPath.split('/').filter(Boolean)
95 const breadcrumbs = [{ name: 'Root', path: '/v2' }]
96
97 let accumulatedPath = ''
98 parts.forEach((part, index) => {
99 accumulatedPath += '/' + part
100 breadcrumbs.push({
101 name: decodeURIComponent(part), // Decode URL encoded characters
102 path: '/v2' + accumulatedPath
103 })
104 })
105
106 return breadcrumbs
107 }
108
109 const breadcrumbs = generateBreadcrumbs(path)
110
111 // Sort files: directories first, then files, all alphabetically
112 const sortFiles = (files: DriveLsEntry[]): DriveLsEntry[] => {
113 return [...files].sort((a, b) => {
114 // Directories first, then files
115 if (a.type === "dir" && b.type === "file") return -1;
116 if (a.type === "file" && b.type === "dir") return 1;
117
118 // Both same type, sort alphabetically by name (case-insensitive)
119 const aName = a.path.split('/').pop() || a.path;
120 const bName = b.path.split('/').pop() || b.path;
121 return aName.toLowerCase().localeCompare(bName.toLowerCase());
122 });
123 };
124
125 // Function to refresh directory contents
126 const refreshDirectory = async () => {
127 try {
128 const encodedPath = path === '/' ? '' : path.split('/').filter(Boolean).map(encodeURIComponent).join('/')
129 const response = await fetch(`/api/fs/${encodedPath}`)
130 if (!response.ok) {
131 throw new Error(`Failed to fetch directory: ${response.statusText}`)
132 }
133 const entries = await response.json()
134 setFiles(sortFiles(entries)) // Sort the files before setting state
135 } catch (err) {
136 console.error('Error refreshing directory:', err)
137 toast({
138 title: "Failed to refresh",
139 description: "Could not refresh directory contents",
140 variant: "destructive"
141 })
142 }
143 }
144
145 // Load directory contents
146 useEffect(() => {
147 async function loadDirectoryData() {
148 try {
149 setLoading(true)
150 setError(null)
151 await refreshDirectory()
152 } catch (err) {
153 setError(err instanceof Error ? err.message : 'Failed to load directory data')
154 console.error('Error loading directory data:', err)
155 } finally {
156 setLoading(false)
157 }
158 }
159
160 loadDirectoryData()
161 }, [path])
162
163 const toggleFileSelection = (filePath: string) => {
164 const newSelected = new Set(selectedFiles)
165 if (newSelected.has(filePath)) {
166 newSelected.delete(filePath)
167 } else {
168 newSelected.add(filePath)
169 }
170 setSelectedFiles(newSelected)
171 }
172
173 const selectAll = () => {
174 setSelectedFiles(new Set(files.map(file => file.path)))
175 }
176
177 const deselectAll = () => {
178 setSelectedFiles(new Set())
179 }
180
181 const openRenameDialog = (item: DriveLsEntry) => {
182 setCurrentItem(item)
183 setNewName(item.path.split('/').pop() || '')
184 setRenameDialogOpen(true)
185 }
186
187 const openInfoDialog = (item: DriveLsEntry) => {
188 setCurrentItem(item)
189 setInfoDialogOpen(true)
190 }
191
192 const openMoveDialog = () => {
193 setMoveDialogOpen(true)
194 }
195
196 const copyPermalink = (item: DriveLsEntry) => {
197 const permalink = `${window.location.origin}/drive/file/${item.path}`
198 navigator.clipboard.writeText(permalink).then(() => {
199 toast({
200 title: "Link copied!",
201 description: "Permalink has been copied to clipboard",
202 })
203 })
204 }
205
206 const handleRename = () => {
207 if (currentItem && newName.trim()) {
208 // TODO: Implement actual rename API call
209 const updatedFiles = files.map((file) => {
210 if (file.path === currentItem.path) {
211 return { ...file, path: file.path.replace(/[^/]+$/, newName.trim()) }
212 }
213 return file
214 })
215 setFiles(sortFiles(updatedFiles)) // Sort after rename
216 setRenameDialogOpen(false)
217 setCurrentItem(null)
218 setNewName("")
219 toast({
220 title: "Renamed successfully",
221 description: `Item renamed to "${newName.trim()}"`,
222 })
223 }
224 }
225
226 const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
227 const uploadedFiles = event.target.files
228 if (!uploadedFiles || uploadedFiles.length === 0) return
229
230 // Validate file count
231 if (uploadedFiles.length > UPLOAD_MAX_FILES) {
232 toast({
233 title: "Too many files",
234 description: `You can only upload up to ${UPLOAD_MAX_FILES} files at once`,
235 variant: "destructive"
236 })
237 return
238 }
239
240 // Validate file sizes
241 const oversizedFiles = Array.from(uploadedFiles).filter(file => file.size > UPLOAD_MAX_FILE_SIZE)
242 if (oversizedFiles.length > 0) {
243 toast({
244 title: "Files too large",
245 description: `Maximum file size is ${formatFileSize(UPLOAD_MAX_FILE_SIZE)}. Found ${oversizedFiles.length} oversized file(s)`,
246 variant: "destructive"
247 })
248 return
249 }
250
251 setUploading(true)
252 let successCount = 0
253 let errorCount = 0
254
255 try {
256 // Upload files sequentially to the current directory
257 for (const file of Array.from(uploadedFiles)) {
258 try {
259 const formData = new FormData()
260 formData.append('file', file)
261
262 // Construct the upload path (current path + filename)
263 // The path should already be properly decoded by Next.js page params
264 const uploadPath = path === '/' ? file.name : `${path.slice(1)}/${file.name}`
265 // Encode each path segment for the URL - Next.js will decode it back for the API
266 const encodedPath = uploadPath.split('/').map(encodeURIComponent).join('/')
267
268 const response = await fetch(`/api/fs/${encodedPath}`, {
269 method: 'PUT',
270 headers: {
271 'AUTH': '1' // Development auth header
272 },
273 body: formData
274 })
275
276 if (!response.ok) {
277 const error = await response.json()
278 throw new Error(error.error || `Upload failed with status ${response.status}`)
279 }
280
281 successCount++
282 } catch (error) {
283 console.error(`Failed to upload ${file.name}:`, error)
284 errorCount++
285 }
286 }
287
288 // Show results
289 if (successCount > 0) {
290 toast({
291 title: "Upload successful",
292 description: `${successCount} file(s) uploaded successfully${errorCount > 0 ? `, ${errorCount} failed` : ''}`
293 })
294
295 // Refresh the directory
296 await refreshDirectory()
297 }
298
299 if (errorCount > 0 && successCount === 0) {
300 toast({
301 title: "Upload failed",
302 description: `All ${errorCount} file(s) failed to upload`,
303 variant: "destructive"
304 })
305 }
306
307 } catch (error) {
308 console.error('Upload error:', error)
309 toast({
310 title: "Upload failed",
311 description: error instanceof Error ? error.message : 'Unknown error occurred',
312 variant: "destructive"
313 })
314 } finally {
315 setUploading(false)
316 // Reset the input
317 event.target.value = ''
318 }
319 }
320
321 const handleDelete = async (itemPaths: string[]) => {
322 // TODO: Implement actual delete API calls
323 const updatedFiles = files.filter(file => !itemPaths.includes(file.path))
324 setFiles(sortFiles(updatedFiles)) // Sort after delete (though order shouldn't change for deletion)
325 // Remove deleted items from selection
326 const newSelected = new Set(selectedFiles)
327 itemPaths.forEach((path) => newSelected.delete(path))
328 setSelectedFiles(newSelected)
329
330 toast({
331 title: "Deleted successfully",
332 description: `${itemPaths.length} item(s) deleted`,
333 })
334 }
335
336 const handleLogin = () => {
337 // Redirect to external auth page (configured via env var)
338 const authUrl = process.env.NEXT_PUBLIC_AUTH_URL || "/auth/login"
339 window.location.href = authUrl
340 }
341
342 const handleLogout = () => {
343 // Handle logout (would typically clear tokens, etc.)
344 setIsLoggedIn(false)
345 // Could also redirect to logout endpoint
346 }
347
348 const handleMove = async (destinationPath: string) => {
349 // TODO: Implement actual move API calls
350 console.log('Moving files:', Array.from(selectedFiles), 'to:', destinationPath)
351 setSelectedFiles(new Set())
352 setMoveDialogOpen(false)
353
354 toast({
355 title: "Moved successfully",
356 description: `${selectedFiles.size} item(s) moved to ${destinationPath}`,
357 })
358
359 // Refresh directory after move
360 await refreshDirectory()
361 }
362
363 return (
364 <div className="container mx-auto p-6 space-y-6">
365 {/* Header with Breadcrumbs */}
366 <div className="flex items-center justify-between">
367 <div className="flex items-center gap-4">
368 <div className="flex items-center gap-2">
369 <HardDrive className="h-6 w-6" />
370 <h1 className="text-2xl font-bold">Drive V2</h1>
371 </div>
372
373 {/* Breadcrumbs */}
374 <nav className="flex items-center gap-1 text-sm text-muted-foreground">
375 {breadcrumbs.map((crumb, index) => (
376 <div key={crumb.path} className="flex items-center gap-1">
377 {index > 0 && <ChevronRight className="h-3 w-3" />}
378 {index === breadcrumbs.length - 1 ? (
379 <span className="text-foreground font-medium">{crumb.name}</span>
380 ) : (
381 <Link
382 href={crumb.path}
383 className="hover:text-foreground transition-colors"
384 >
385 {crumb.name}
386 </Link>
387 )}
388 </div>
389 ))}
390 </nav>
391 </div>
392
393 <div className="flex items-center gap-2">
394 <Button
395 onClick={() => fileInputRef.current?.click()}
396 disabled={uploading}
397 >
398 <Upload className="mr-2 h-4 w-4" />
399 {uploading ? "Uploading..." : "Upload Files"}
400 </Button>
401 {isLoggedIn ? (
402 <Button variant="outline" onClick={handleLogout}>
403 <LogOut className="mr-2 h-4 w-4" />
404 Logout
405 </Button>
406 ) : (
407 <Button onClick={handleLogin}>
408 <LogIn className="mr-2 h-4 w-4" />
409 Login
410 </Button>
411 )}
412 </div>
413 </div>
414
415 {/* Loading State */}
416 {loading && (
417 <div className="flex items-center justify-center py-8">
418 <div className="text-center">
419 <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto mb-2"></div>
420 <p className="text-sm text-muted-foreground">Loading directory contents...</p>
421 </div>
422 </div>
423 )}
424
425 {/* Error State */}
426 {error && (
427 <div className="bg-red-50 border border-red-200 rounded-lg p-4">
428 <div className="text-sm text-red-800">
429 <strong>Error loading directory:</strong> {error}
430 </div>
431 </div>
432 )}
433
434 {/* Storage Info */}
435 {!loading && !error && (
436 <div className="bg-card rounded-lg border p-4">
437 <div className="flex items-center justify-between mb-2">
438 <span className="text-sm font-medium">Storage Usage</span>
439 <span className="text-sm text-muted-foreground">
440 {formatFileSize(usedStorage)} of {formatFileSize(maxStorage)} used
441 </span>
442 </div>
443 <Progress value={storagePercentage} className="h-2" />
444 <div className="flex justify-between text-xs text-muted-foreground mt-1">
445 <span>{storagePercentage.toFixed(1)}% used</span>
446 <span>{formatFileSize(maxStorage - usedStorage)} available</span>
447 </div>
448 </div>
449 )}
450
451 {/* Bulk Actions */}
452 {!loading && !error && selectedFiles.size > 0 && (
453 <div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-center justify-between">
454 <div className="flex items-center gap-4">
455 <span className="text-sm font-medium text-blue-900">
456 {selectedFiles.size} item{selectedFiles.size !== 1 ? "s" : ""} selected
457 </span>
458 <Button variant="outline" size="sm" onClick={deselectAll}>
459 Deselect All
460 </Button>
461 </div>
462 <div className="flex items-center gap-2">
463 <Button
464 variant="outline"
465 size="sm"
466 onClick={openMoveDialog}
467 >
468 <Move className="mr-2 h-4 w-4" />
469 Move Selected
470 </Button>
471 <Button
472 variant="outline"
473 size="sm"
474 onClick={() => handleDelete(Array.from(selectedFiles))}
475 className="text-red-600 hover:text-red-700"
476 >
477 <Trash2 className="mr-2 h-4 w-4" />
478 Delete Selected
479 </Button>
480 </div>
481 </div>
482 )}
483
484 {/* File Table */}
485 {!loading && !error && (
486 <div className="border rounded-lg">
487 <Table>
488 <TableHeader>
489 <TableRow>
490 <TableHead className="w-[40px]"></TableHead>
491 <TableHead>Name</TableHead>
492 <TableHead>Size</TableHead>
493 <TableHead>Modified</TableHead>
494 <TableHead className="w-[50px]">Actions</TableHead>
495 </TableRow>
496 </TableHeader>
497 <TableBody>
498 {files.length === 0 ? (
499 <TableRow>
500 <TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
501 This directory is empty
502 </TableCell>
503 </TableRow>
504 ) : (
505 files.map((file) => {
506 const isSelected = selectedFiles.has(file.path)
507 const fileName = file.path.split('/').pop() || file.path
508
509 return (
510 <TableRow
511 key={file.path}
512 className={`hover:bg-muted/50 ${isSelected ? "bg-muted/30" : ""}`}
513 >
514 <TableCell className="w-[40px]" onClick={(e) => e.stopPropagation()}>
515 <Checkbox
516 checked={isSelected}
517 onCheckedChange={() => toggleFileSelection(file.path)}
518 />
519 </TableCell>
520 <TableCell className="font-medium">
521 <div className="flex items-center gap-2">
522 {file.type === "dir" ? (
523 <>
524 <Folder className="h-4 w-4 text-blue-500" />
525 <Link
526 href={`/v2${file.path}`}
527 className="text-blue-600 hover:text-blue-800 hover:underline cursor-pointer"
528 >
529 {fileName}
530 </Link>
531 </>
532 ) : (
533 <>
534 <File className="h-4 w-4 text-gray-500" />
535 {file.blob ? (
536 <a
537 href={`/blob/${file.blob}?filename=${encodeURIComponent(fileName)}`}
538 className="text-blue-600 hover:text-blue-800 hover:underline cursor-pointer"
539 target="_blank"
540 rel="noopener noreferrer"
541 >
542 {fileName}
543 </a>
544 ) : (
545 <span>{fileName}</span>
546 )}
547 </>
548 )}
549 </div>
550 </TableCell>
551 <TableCell>{formatFileSize(file.size || 0)}</TableCell>
552 <TableCell>{formatDate(file.lastmod)}</TableCell>
553 <TableCell onClick={(e) => e.stopPropagation()}>
554 <DropdownMenu>
555 <DropdownMenuTrigger asChild>
556 <Button variant="ghost" size="sm" className="h-8 w-8 p-0">
557 <MoreHorizontal className="h-4 w-4" />
558 </Button>
559 </DropdownMenuTrigger>
560 <DropdownMenuContent align="end">
561 <DropdownMenuItem onClick={() => openRenameDialog(file)}>
562 <Edit className="mr-2 h-4 w-4" />
563 Rename
564 </DropdownMenuItem>
565 <DropdownMenuItem onClick={() => copyPermalink(file)}>
566 <LinkIcon className="mr-2 h-4 w-4" />
567 Copy Permalink
568 </DropdownMenuItem>
569 <DropdownMenuItem onClick={() => openInfoDialog(file)}>
570 <Info className="mr-2 h-4 w-4" />
571 Info
572 </DropdownMenuItem>
573 <DropdownMenuSeparator />
574 <DropdownMenuItem
575 onClick={() => handleDelete([file.path])}
576 className="text-red-600"
577 >
578 <Trash2 className="mr-2 h-4 w-4" />
579 Delete
580 </DropdownMenuItem>
581 </DropdownMenuContent>
582 </DropdownMenu>
583 </TableCell>
584 </TableRow>
585 )
586 })
587 )}
588 </TableBody>
589 </Table>
590 </div>
591 )}
592
593 {/* Rename Dialog */}
594 <Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
595 <DialogContent>
596 <DialogHeader>
597 <DialogTitle>Rename {currentItem?.type === "dir" ? "Folder" : "File"}</DialogTitle>
598 </DialogHeader>
599 <div className="space-y-4">
600 <div>
601 <Label htmlFor="newName">New Name</Label>
602 <Input
603 id="newName"
604 value={newName}
605 onChange={(e) => setNewName(e.target.value)}
606 onKeyDown={(e) => {
607 if (e.key === "Enter") {
608 handleRename()
609 }
610 }}
611 placeholder="Enter new name"
612 />
613 </div>
614 <div className="flex justify-end gap-2">
615 <Button variant="outline" onClick={() => setRenameDialogOpen(false)}>
616 Cancel
617 </Button>
618 <Button onClick={handleRename} disabled={!newName.trim()}>
619 Rename
620 </Button>
621 </div>
622 </div>
623 </DialogContent>
624 </Dialog>
625
626 {/* Info Dialog */}
627 <Dialog open={infoDialogOpen} onOpenChange={setInfoDialogOpen}>
628 <DialogContent className="max-w-md">
629 <DialogHeader>
630 <DialogTitle className="flex items-center gap-2">
631 {currentItem?.type === "dir" ? (
632 <Folder className="h-5 w-5 text-blue-500" />
633 ) : (
634 <File className="h-5 w-5 text-gray-500" />
635 )}
636 {currentItem?.type === "dir" ? "Folder" : "File"} Information
637 </DialogTitle>
638 </DialogHeader>
639 {currentItem && (
640 <div className="space-y-4">
641 <div className="space-y-3">
642 <div>
643 <Label className="text-sm font-medium text-muted-foreground">Name</Label>
644 <p className="text-sm break-words">{currentItem.path.split('/').pop()}</p>
645 </div>
646 <div>
647 <Label className="text-sm font-medium text-muted-foreground">Size</Label>
648 <p className="text-sm">{formatFileSize(currentItem.size || 0)}</p>
649 </div>
650 <div>
651 <Label className="text-sm font-medium text-muted-foreground">Modified</Label>
652 <p className="text-sm">{formatDateTime(currentItem.lastmod)}</p>
653 </div>
654 <div>
655 <Label className="text-sm font-medium text-muted-foreground">Modified By</Label>
656 <p className="text-sm">{currentItem.author}</p>
657 </div>
658 <div>
659 <Label className="text-sm font-medium text-muted-foreground">Type</Label>
660 <p className="text-sm capitalize">{currentItem.type}</p>
661 </div>
662 <div>
663 <Label className="text-sm font-medium text-muted-foreground">Path</Label>
664 <p className="text-sm font-mono text-xs">{currentItem.path}</p>
665 </div>
666 </div>
667 <div className="flex justify-end">
668 <Button variant="outline" onClick={() => setInfoDialogOpen(false)}>
669 Close
670 </Button>
671 </div>
672 </div>
673 )}
674 </DialogContent>
675 </Dialog>
676
677 {/* Move Dialog */}
678 <V2MoveDialog
679 open={moveDialogOpen}
680 onOpenChange={setMoveDialogOpen}
681 selectedCount={selectedFiles.size}
682 onMove={handleMove}
683 />
684
685 <input
686 ref={fileInputRef}
687 type="file"
688 multiple
689 className="hidden"
690 onChange={handleFileUpload}
691 />
692 </div>
693 )
694} \ No newline at end of file
diff --git a/frontend/components/v2/V2MoveDialog.tsx b/frontend/components/v2/V2MoveDialog.tsx
new file mode 100644
index 0000000..7cedde0
--- /dev/null
+++ b/frontend/components/v2/V2MoveDialog.tsx
@@ -0,0 +1,169 @@
1"use client"
2
3import React, { useState, useEffect } from "react"
4import { Search, Folder, Move } from "lucide-react"
5import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
6import { Input } from "@/components/ui/input"
7import { Button } from "@/components/ui/button"
8import { ScrollArea } from "@/components/ui/scroll-area"
9import { toast } from "@/hooks/use-toast"
10import { DriveLsEntry } from "@/lib/drive_types"
11
12interface V2MoveDialogProps {
13 open: boolean
14 onOpenChange: (open: boolean) => void
15 selectedCount: number
16 onMove: (destinationPath: string) => void
17}
18
19export function V2MoveDialog({ open, onOpenChange, selectedCount, onMove }: V2MoveDialogProps) {
20 const [directories, setDirectories] = useState<DriveLsEntry[]>([])
21 const [filteredDirectories, setFilteredDirectories] = useState<DriveLsEntry[]>([])
22 const [searchQuery, setSearchQuery] = useState("")
23 const [selectedDirectory, setSelectedDirectory] = useState<string | null>(null)
24 const [loading, setLoading] = useState(false)
25
26 // Load all directories when dialog opens
27 useEffect(() => {
28 if (open) {
29 loadDirectories()
30 }
31 }, [open])
32
33 // Filter directories based on search query
34 useEffect(() => {
35 if (!searchQuery.trim()) {
36 setFilteredDirectories(directories)
37 } else {
38 const query = searchQuery.toLowerCase()
39 const filtered = directories.filter(dir =>
40 dir.path.toLowerCase().includes(query)
41 )
42 setFilteredDirectories(filtered)
43 }
44 }, [searchQuery, directories])
45
46 const loadDirectories = async () => {
47 setLoading(true)
48 try {
49 const response = await fetch('/api/directories')
50 if (!response.ok) {
51 throw new Error(`Failed to fetch directories: ${response.statusText}`)
52 }
53 const dirs = await response.json()
54 setDirectories(dirs)
55 setFilteredDirectories(dirs)
56 } catch (error) {
57 console.error('Error loading directories:', error)
58 toast({
59 title: "Failed to load directories",
60 description: error instanceof Error ? error.message : 'Unknown error occurred',
61 variant: "destructive"
62 })
63 } finally {
64 setLoading(false)
65 }
66 }
67
68 const handleMove = () => {
69 if (selectedDirectory) {
70 onMove(selectedDirectory)
71 // Reset dialog state
72 setSelectedDirectory(null)
73 setSearchQuery("")
74 }
75 }
76
77 const handleClose = () => {
78 onOpenChange(false)
79 // Reset dialog state
80 setSelectedDirectory(null)
81 setSearchQuery("")
82 }
83
84 return (
85 <Dialog open={open} onOpenChange={handleClose}>
86 <DialogContent className="max-w-md max-h-[80vh]">
87 <DialogHeader>
88 <DialogTitle className="flex items-center gap-2">
89 <Move className="h-5 w-5" />
90 Move {selectedCount} item{selectedCount !== 1 ? "s" : ""}
91 </DialogTitle>
92 </DialogHeader>
93
94 <div className="space-y-4">
95 {/* Search Input */}
96 <div className="relative">
97 <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
98 <Input
99 placeholder="Search directories..."
100 value={searchQuery}
101 onChange={(e) => setSearchQuery(e.target.value)}
102 className="pl-10"
103 />
104 </div>
105
106 {/* Directory List */}
107 <ScrollArea className="h-[300px] border rounded-md">
108 <div className="p-2">
109 {loading ? (
110 <div className="flex items-center justify-center py-8">
111 <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
112 </div>
113 ) : filteredDirectories.length === 0 ? (
114 <div className="text-center py-8 text-muted-foreground">
115 {searchQuery ? 'No directories found matching your search' : 'No directories available'}
116 </div>
117 ) : (
118 <div className="space-y-1">
119 {filteredDirectories.map((directory) => (
120 <div
121 key={directory.path}
122 className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-colors ${
123 selectedDirectory === directory.path
124 ? 'bg-blue-50 border border-blue-200'
125 : 'hover:bg-muted/50'
126 }`}
127 onClick={() => setSelectedDirectory(directory.path)}
128 >
129 <Folder className="h-4 w-4 text-blue-500 flex-shrink-0" />
130 <span className="text-sm font-mono break-all">
131 {directory.path}
132 </span>
133 </div>
134 ))}
135 </div>
136 )}
137 </div>
138 </ScrollArea>
139
140 {/* Selected Directory Display */}
141 {selectedDirectory && (
142 <div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
143 <div className="text-sm font-medium text-blue-900 mb-1">
144 Moving to:
145 </div>
146 <div className="text-sm font-mono text-blue-700">
147 {selectedDirectory}
148 </div>
149 </div>
150 )}
151
152 {/* Action Buttons */}
153 <div className="flex justify-end gap-2">
154 <Button variant="outline" onClick={handleClose}>
155 Cancel
156 </Button>
157 <Button
158 onClick={handleMove}
159 disabled={!selectedDirectory || loading}
160 >
161 <Move className="mr-2 h-4 w-4" />
162 Move Here
163 </Button>
164 </div>
165 </div>
166 </DialogContent>
167 </Dialog>
168 )
169} \ No newline at end of file
diff --git a/frontend/lib/drive_server.ts b/frontend/lib/drive_server.ts
index 81e9321..992a287 100644
--- a/frontend/lib/drive_server.ts
+++ b/frontend/lib/drive_server.ts
@@ -174,6 +174,13 @@ export async function Drive_tree(): Promise<DriveTreeResponse> {
174 return { root: calculateSizesAndSort(rootNodes) }; 174 return { root: calculateSizesAndSort(rootNodes) };
175} 175}
176 176
177/// lists only directories (recursively) from the given path
178export async function Drive_ls_directories(path: string = '/'): Promise<DriveLsEntry[]> {
179 // Get all entries recursively and filter for directories
180 const allEntries = await Drive_ls(path, true)
181 return allEntries.filter(entry => entry.type === 'dir')
182}
183
177/// returns the log entries from the drive 184/// returns the log entries from the drive
178export async function Drive_log(): Promise<DriveLogEntry[]> { 185export async function Drive_log(): Promise<DriveLogEntry[]> {
179 const result = spawnSync('fctdrive', ['log'], { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 }) 186 const result = spawnSync('fctdrive', ['log'], { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 })
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 2a93c83..6680145 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -13,6 +13,7 @@
13 "@radix-ui/react-dropdown-menu": "^2.1.15", 13 "@radix-ui/react-dropdown-menu": "^2.1.15",
14 "@radix-ui/react-label": "^2.1.7", 14 "@radix-ui/react-label": "^2.1.7",
15 "@radix-ui/react-progress": "^1.1.7", 15 "@radix-ui/react-progress": "^1.1.7",
16 "@radix-ui/react-scroll-area": "^1.2.1",
16 "@radix-ui/react-slot": "^1.2.3", 17 "@radix-ui/react-slot": "^1.2.3",
17 "@radix-ui/react-toast": "^1.2.14", 18 "@radix-ui/react-toast": "^1.2.14",
18 "class-variance-authority": "^0.7.1", 19 "class-variance-authority": "^0.7.1",
@@ -1016,6 +1017,12 @@
1016 "node": ">=12.4.0" 1017 "node": ">=12.4.0"
1017 } 1018 }
1018 }, 1019 },
1020 "node_modules/@radix-ui/number": {
1021 "version": "1.1.1",
1022 "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
1023 "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
1024 "license": "MIT"
1025 },
1019 "node_modules/@radix-ui/primitive": { 1026 "node_modules/@radix-ui/primitive": {
1020 "version": "1.1.2", 1027 "version": "1.1.2",
1021 "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", 1028 "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
@@ -1517,6 +1524,37 @@
1517 } 1524 }
1518 } 1525 }
1519 }, 1526 },
1527 "node_modules/@radix-ui/react-scroll-area": {
1528 "version": "1.2.9",
1529 "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz",
1530 "integrity": "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A==",
1531 "license": "MIT",
1532 "dependencies": {
1533 "@radix-ui/number": "1.1.1",
1534 "@radix-ui/primitive": "1.1.2",
1535 "@radix-ui/react-compose-refs": "1.1.2",
1536 "@radix-ui/react-context": "1.1.2",
1537 "@radix-ui/react-direction": "1.1.1",
1538 "@radix-ui/react-presence": "1.1.4",
1539 "@radix-ui/react-primitive": "2.1.3",
1540 "@radix-ui/react-use-callback-ref": "1.1.1",
1541 "@radix-ui/react-use-layout-effect": "1.1.1"
1542 },
1543 "peerDependencies": {
1544 "@types/react": "*",
1545 "@types/react-dom": "*",
1546 "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
1547 "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
1548 },
1549 "peerDependenciesMeta": {
1550 "@types/react": {
1551 "optional": true
1552 },
1553 "@types/react-dom": {
1554 "optional": true
1555 }
1556 }
1557 },
1520 "node_modules/@radix-ui/react-slot": { 1558 "node_modules/@radix-ui/react-slot": {
1521 "version": "1.2.3", 1559 "version": "1.2.3",
1522 "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", 1560 "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index d7922cf..d3762a2 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -14,6 +14,7 @@
14 "@radix-ui/react-dropdown-menu": "^2.1.15", 14 "@radix-ui/react-dropdown-menu": "^2.1.15",
15 "@radix-ui/react-label": "^2.1.7", 15 "@radix-ui/react-label": "^2.1.7",
16 "@radix-ui/react-progress": "^1.1.7", 16 "@radix-ui/react-progress": "^1.1.7",
17 "@radix-ui/react-scroll-area": "^1.2.1",
17 "@radix-ui/react-slot": "^1.2.3", 18 "@radix-ui/react-slot": "^1.2.3",
18 "@radix-ui/react-toast": "^1.2.14", 19 "@radix-ui/react-toast": "^1.2.14",
19 "class-variance-authority": "^0.7.1", 20 "class-variance-authority": "^0.7.1",