summaryrefslogtreecommitdiff
path: root/frontend
diff options
context:
space:
mode:
authordiogo464 <[email protected]>2025-08-12 16:24:14 +0100
committerdiogo464 <[email protected]>2025-08-12 16:24:14 +0100
commit507d9ee9ae524edd4e39942b735d987aa5d48359 (patch)
tree056721408b0fa1c7e55916376ea5508febea90a2 /frontend
parent519bb45b89591b78b3ef65e4b937c53482552887 (diff)
Convert V2 UI to server-side data loading
- Convert page components to async server components using Drive_ls() - Split V2DirectoryView into server component (data processing) and V2DirectoryClient (interactivity) - Remove client-side useEffect + fetch API calls for directory listing - Data now loaded server-side and passed as props for better static rendering - Mutations still use page refresh to show updated data 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
Diffstat (limited to 'frontend')
-rw-r--r--frontend/app/v2/[...path]/page.tsx6
-rw-r--r--frontend/app/v2/page.tsx6
-rw-r--r--frontend/components/v2/V2DirectoryClient.tsx591
-rw-r--r--frontend/components/v2/V2DirectoryView.tsx723
4 files changed, 641 insertions, 685 deletions
diff --git a/frontend/app/v2/[...path]/page.tsx b/frontend/app/v2/[...path]/page.tsx
index 4af0167..3b4cbb8 100644
--- a/frontend/app/v2/[...path]/page.tsx
+++ b/frontend/app/v2/[...path]/page.tsx
@@ -1,4 +1,5 @@
1import { V2DirectoryView } from "@/components/v2/V2DirectoryView" 1import { V2DirectoryView } from "@/components/v2/V2DirectoryView"
2import { Drive_ls } from "@/lib/drive_server"
2 3
3export default async function V2DirectoryPage({ 4export default async function V2DirectoryPage({
4 params, 5 params,
@@ -7,6 +8,7 @@ export default async function V2DirectoryPage({
7}) { 8}) {
8 const { path: pathSegments } = await params 9 const { path: pathSegments } = await params
9 const currentPath = '/' + (pathSegments?.join('/') || '') 10 const currentPath = '/' + (pathSegments?.join('/') || '')
10 11
11 return <V2DirectoryView path={currentPath} /> 12 const files = await Drive_ls(currentPath, false)
13 return <V2DirectoryView path={currentPath} files={files} />
12} \ No newline at end of file 14} \ No newline at end of file
diff --git a/frontend/app/v2/page.tsx b/frontend/app/v2/page.tsx
index e693c77..09418a2 100644
--- a/frontend/app/v2/page.tsx
+++ b/frontend/app/v2/page.tsx
@@ -1,5 +1,7 @@
1import { V2DirectoryView } from "@/components/v2/V2DirectoryView" 1import { V2DirectoryView } from "@/components/v2/V2DirectoryView"
2import { Drive_ls } from "@/lib/drive_server"
2 3
3export default function V2RootPage() { 4export default async function V2RootPage() {
4 return <V2DirectoryView path="/" /> 5 const files = await Drive_ls("/", false)
6 return <V2DirectoryView path="/" files={files} />
5} \ No newline at end of file 7} \ No newline at end of file
diff --git a/frontend/components/v2/V2DirectoryClient.tsx b/frontend/components/v2/V2DirectoryClient.tsx
new file mode 100644
index 0000000..0d9a63a
--- /dev/null
+++ b/frontend/components/v2/V2DirectoryClient.tsx
@@ -0,0 +1,591 @@
1"use client"
2
3import type React from "react"
4import { useState, useRef } 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} from "lucide-react"
21import { Button } from "@/components/ui/button"
22import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
23import { Progress } from "@/components/ui/progress"
24import {
25 DropdownMenu,
26 DropdownMenuContent,
27 DropdownMenuItem,
28 DropdownMenuTrigger,
29 DropdownMenuSeparator,
30} from "@/components/ui/dropdown-menu"
31import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
32import { Input } from "@/components/ui/input"
33import { Label } from "@/components/ui/label"
34import { Checkbox } from "@/components/ui/checkbox"
35import { toast } from "@/hooks/use-toast"
36import { DriveLsEntry } from "@/lib/drive_types"
37import { UPLOAD_MAX_FILE_SIZE, UPLOAD_MAX_FILES } from "@/lib/constants"
38import { V2MoveDialog } from "./V2MoveDialog"
39
40function formatFileSize(bytes: number): string {
41 if (bytes === 0) return "0 Bytes"
42 const k = 1024
43 const sizes = ["Bytes", "KB", "MB", "GB"]
44 const i = Math.floor(Math.log(bytes) / Math.log(k))
45 return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
46}
47
48function formatDate(timestamp: number): string {
49 return new Date(timestamp * 1000).toISOString().split('T')[0]
50}
51
52function formatDateTime(timestamp: number): string {
53 const date = new Date(timestamp * 1000)
54 const dateStr = date.toISOString().split('T')[0]
55 const timeStr = date.toLocaleTimeString('en-US', {
56 hour12: false,
57 hour: '2-digit',
58 minute: '2-digit',
59 second: '2-digit'
60 })
61 return `${dateStr} at ${timeStr}`
62}
63
64interface Breadcrumb {
65 name: string
66 path: string
67}
68
69interface V2DirectoryClientProps {
70 path: string
71 files: DriveLsEntry[]
72 breadcrumbs: Breadcrumb[]
73}
74
75export function V2DirectoryClient({ path, files, breadcrumbs }: V2DirectoryClientProps) {
76 const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set())
77 const [renameDialogOpen, setRenameDialogOpen] = useState(false)
78 const [infoDialogOpen, setInfoDialogOpen] = useState(false)
79 const [moveDialogOpen, setMoveDialogOpen] = useState(false)
80 const [currentItem, setCurrentItem] = useState<DriveLsEntry | null>(null)
81 const [newName, setNewName] = useState("")
82 const fileInputRef = useRef<HTMLInputElement>(null)
83 const [uploading, setUploading] = useState(false)
84
85 const [isLoggedIn, setIsLoggedIn] = useState(true) // Mock logged in state
86
87 const maxStorage = 1073741824 // 1GB
88 const usedStorage = 0 // TODO: Calculate from files if needed
89 const storagePercentage = (usedStorage / maxStorage) * 100
90
91 const toggleFileSelection = (filePath: string) => {
92 const newSelected = new Set(selectedFiles)
93 if (newSelected.has(filePath)) {
94 newSelected.delete(filePath)
95 } else {
96 newSelected.add(filePath)
97 }
98 setSelectedFiles(newSelected)
99 }
100
101 const selectAll = () => {
102 setSelectedFiles(new Set(files.map(file => file.path)))
103 }
104
105 const deselectAll = () => {
106 setSelectedFiles(new Set())
107 }
108
109 const openRenameDialog = (item: DriveLsEntry) => {
110 setCurrentItem(item)
111 setNewName(item.path.split('/').pop() || '')
112 setRenameDialogOpen(true)
113 }
114
115 const openInfoDialog = (item: DriveLsEntry) => {
116 setCurrentItem(item)
117 setInfoDialogOpen(true)
118 }
119
120 const openMoveDialog = () => {
121 setMoveDialogOpen(true)
122 }
123
124 const copyPermalink = (item: DriveLsEntry) => {
125 const permalink = `${window.location.origin}/drive/file/${item.path}`
126 navigator.clipboard.writeText(permalink).then(() => {
127 toast({
128 title: "Link copied!",
129 description: "Permalink has been copied to clipboard",
130 })
131 })
132 }
133
134 const handleRename = () => {
135 if (currentItem && newName.trim()) {
136 // TODO: Implement actual rename API call
137 setRenameDialogOpen(false)
138 setCurrentItem(null)
139 setNewName("")
140 toast({
141 title: "Renamed successfully",
142 description: `Item renamed to "${newName.trim()}"`,
143 })
144 // Refresh page to show changes
145 window.location.reload()
146 }
147 }
148
149 const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
150 const uploadedFiles = event.target.files
151 if (!uploadedFiles || uploadedFiles.length === 0) return
152
153 // Validate file count
154 if (uploadedFiles.length > UPLOAD_MAX_FILES) {
155 toast({
156 title: "Too many files",
157 description: `You can only upload up to ${UPLOAD_MAX_FILES} files at once`,
158 variant: "destructive"
159 })
160 return
161 }
162
163 // Validate file sizes
164 const oversizedFiles = Array.from(uploadedFiles).filter(file => file.size > UPLOAD_MAX_FILE_SIZE)
165 if (oversizedFiles.length > 0) {
166 toast({
167 title: "Files too large",
168 description: `Maximum file size is ${formatFileSize(UPLOAD_MAX_FILE_SIZE)}. Found ${oversizedFiles.length} oversized file(s)`,
169 variant: "destructive"
170 })
171 return
172 }
173
174 setUploading(true)
175 let successCount = 0
176 let errorCount = 0
177
178 try {
179 // Upload files sequentially to the current directory
180 for (const file of Array.from(uploadedFiles)) {
181 try {
182 const formData = new FormData()
183 formData.append('file', file)
184
185 // Construct the upload path (current path + filename)
186 const uploadPath = path === '/' ? file.name : `${path.slice(1)}/${file.name}`
187 // Encode each path segment for the URL - Next.js will decode it back for the API
188 const encodedPath = uploadPath.split('/').map(encodeURIComponent).join('/')
189
190 const response = await fetch(`/api/fs/${encodedPath}`, {
191 method: 'PUT',
192 headers: {
193 'AUTH': '1' // Development auth header
194 },
195 body: formData
196 })
197
198 if (!response.ok) {
199 const error = await response.json()
200 throw new Error(error.error || `Upload failed with status ${response.status}`)
201 }
202
203 successCount++
204 } catch (error) {
205 console.error(`Failed to upload ${file.name}:`, error)
206 errorCount++
207 }
208 }
209
210 // Show results
211 if (successCount > 0) {
212 toast({
213 title: "Upload successful",
214 description: `${successCount} file(s) uploaded successfully${errorCount > 0 ? `, ${errorCount} failed` : ''}`
215 })
216
217 // Refresh page to show changes
218 window.location.reload()
219 }
220
221 if (errorCount > 0 && successCount === 0) {
222 toast({
223 title: "Upload failed",
224 description: `All ${errorCount} file(s) failed to upload`,
225 variant: "destructive"
226 })
227 }
228
229 } catch (error) {
230 console.error('Upload error:', error)
231 toast({
232 title: "Upload failed",
233 description: error instanceof Error ? error.message : 'Unknown error occurred',
234 variant: "destructive"
235 })
236 } finally {
237 setUploading(false)
238 // Reset the input
239 event.target.value = ''
240 }
241 }
242
243 const handleDelete = async (itemPaths: string[]) => {
244 // TODO: Implement actual delete API calls
245 setSelectedFiles(new Set())
246
247 toast({
248 title: "Deleted successfully",
249 description: `${itemPaths.length} item(s) deleted`,
250 })
251
252 // Refresh page to show changes
253 window.location.reload()
254 }
255
256 const handleLogin = () => {
257 // Redirect to external auth page (configured via env var)
258 const authUrl = process.env.NEXT_PUBLIC_AUTH_URL || "/auth/login"
259 window.location.href = authUrl
260 }
261
262 const handleLogout = () => {
263 // Handle logout (would typically clear tokens, etc.)
264 setIsLoggedIn(false)
265 // Could also redirect to logout endpoint
266 }
267
268 const handleMove = async (destinationPath: string) => {
269 // TODO: Implement actual move API calls
270 console.log('Moving files:', Array.from(selectedFiles), 'to:', destinationPath)
271 setSelectedFiles(new Set())
272 setMoveDialogOpen(false)
273
274 toast({
275 title: "Moved successfully",
276 description: `${selectedFiles.size} item(s) moved to ${destinationPath}`,
277 })
278
279 // Refresh page to show changes
280 window.location.reload()
281 }
282
283 return (
284 <div className="container mx-auto p-6 space-y-6">
285 {/* Header with Breadcrumbs */}
286 <div className="flex items-center justify-between">
287 <div className="flex items-center gap-4">
288 <div className="flex items-center gap-2">
289 <HardDrive className="h-6 w-6" />
290 <h1 className="text-2xl font-bold">Drive V2</h1>
291 </div>
292
293 {/* Breadcrumbs */}
294 <nav className="flex items-center gap-1 text-sm text-muted-foreground">
295 {breadcrumbs.map((crumb, index) => (
296 <div key={crumb.path} className="flex items-center gap-1">
297 {index > 0 && <ChevronRight className="h-3 w-3" />}
298 {index === breadcrumbs.length - 1 ? (
299 <span className="text-foreground font-medium">{crumb.name}</span>
300 ) : (
301 <Link
302 href={crumb.path}
303 className="hover:text-foreground transition-colors"
304 >
305 {crumb.name}
306 </Link>
307 )}
308 </div>
309 ))}
310 </nav>
311 </div>
312
313 <div className="flex items-center gap-2">
314 <Button
315 onClick={() => fileInputRef.current?.click()}
316 disabled={uploading}
317 >
318 <Upload className="mr-2 h-4 w-4" />
319 {uploading ? "Uploading..." : "Upload Files"}
320 </Button>
321 {isLoggedIn ? (
322 <Button variant="outline" onClick={handleLogout}>
323 <LogOut className="mr-2 h-4 w-4" />
324 Logout
325 </Button>
326 ) : (
327 <Button onClick={handleLogin}>
328 <LogIn className="mr-2 h-4 w-4" />
329 Login
330 </Button>
331 )}
332 </div>
333 </div>
334
335 {/* Storage Info */}
336 <div className="bg-card rounded-lg border p-4">
337 <div className="flex items-center justify-between mb-2">
338 <span className="text-sm font-medium">Storage Usage</span>
339 <span className="text-sm text-muted-foreground">
340 {formatFileSize(usedStorage)} of {formatFileSize(maxStorage)} used
341 </span>
342 </div>
343 <Progress value={storagePercentage} className="h-2" />
344 <div className="flex justify-between text-xs text-muted-foreground mt-1">
345 <span>{storagePercentage.toFixed(1)}% used</span>
346 <span>{formatFileSize(maxStorage - usedStorage)} available</span>
347 </div>
348 </div>
349
350 {/* Bulk Actions */}
351 {selectedFiles.size > 0 && (
352 <div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-center justify-between">
353 <div className="flex items-center gap-4">
354 <span className="text-sm font-medium text-blue-900">
355 {selectedFiles.size} item{selectedFiles.size !== 1 ? "s" : ""} selected
356 </span>
357 <Button variant="outline" size="sm" onClick={deselectAll}>
358 Deselect All
359 </Button>
360 </div>
361 <div className="flex items-center gap-2">
362 <Button
363 variant="outline"
364 size="sm"
365 onClick={openMoveDialog}
366 >
367 <Move className="mr-2 h-4 w-4" />
368 Move Selected
369 </Button>
370 <Button
371 variant="outline"
372 size="sm"
373 onClick={() => handleDelete(Array.from(selectedFiles))}
374 className="text-red-600 hover:text-red-700"
375 >
376 <Trash2 className="mr-2 h-4 w-4" />
377 Delete Selected
378 </Button>
379 </div>
380 </div>
381 )}
382
383 {/* File Table */}
384 <div className="border rounded-lg">
385 <Table>
386 <TableHeader>
387 <TableRow>
388 <TableHead className="w-[40px]"></TableHead>
389 <TableHead>Name</TableHead>
390 <TableHead>Size</TableHead>
391 <TableHead>Modified</TableHead>
392 <TableHead className="w-[50px]">Actions</TableHead>
393 </TableRow>
394 </TableHeader>
395 <TableBody>
396 {files.length === 0 ? (
397 <TableRow>
398 <TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
399 This directory is empty
400 </TableCell>
401 </TableRow>
402 ) : (
403 files.map((file) => {
404 const isSelected = selectedFiles.has(file.path)
405 const fileName = file.path.split('/').pop() || file.path
406
407 return (
408 <TableRow
409 key={file.path}
410 className={`hover:bg-muted/50 ${isSelected ? "bg-muted/30" : ""}`}
411 >
412 <TableCell className="w-[40px]" onClick={(e) => e.stopPropagation()}>
413 <Checkbox
414 checked={isSelected}
415 onCheckedChange={() => toggleFileSelection(file.path)}
416 />
417 </TableCell>
418 <TableCell className="font-medium">
419 <div className="flex items-center gap-2">
420 {file.type === "dir" ? (
421 <>
422 <Folder className="h-4 w-4 text-blue-500" />
423 <Link
424 href={`/v2${file.path}`}
425 className="text-blue-600 hover:text-blue-800 hover:underline cursor-pointer"
426 >
427 {fileName}
428 </Link>
429 </>
430 ) : (
431 <>
432 <File className="h-4 w-4 text-gray-500" />
433 {file.blob ? (
434 <a
435 href={`/blob/${file.blob}?filename=${encodeURIComponent(fileName)}`}
436 className="text-blue-600 hover:text-blue-800 hover:underline cursor-pointer"
437 target="_blank"
438 rel="noopener noreferrer"
439 >
440 {fileName}
441 </a>
442 ) : (
443 <span>{fileName}</span>
444 )}
445 </>
446 )}
447 </div>
448 </TableCell>
449 <TableCell>{formatFileSize(file.size || 0)}</TableCell>
450 <TableCell>{formatDate(file.lastmod)}</TableCell>
451 <TableCell onClick={(e) => e.stopPropagation()}>
452 <DropdownMenu>
453 <DropdownMenuTrigger asChild>
454 <Button variant="ghost" size="sm" className="h-8 w-8 p-0">
455 <MoreHorizontal className="h-4 w-4" />
456 </Button>
457 </DropdownMenuTrigger>
458 <DropdownMenuContent align="end">
459 <DropdownMenuItem onClick={() => openRenameDialog(file)}>
460 <Edit className="mr-2 h-4 w-4" />
461 Rename
462 </DropdownMenuItem>
463 <DropdownMenuItem onClick={() => copyPermalink(file)}>
464 <LinkIcon className="mr-2 h-4 w-4" />
465 Copy Permalink
466 </DropdownMenuItem>
467 <DropdownMenuItem onClick={() => openInfoDialog(file)}>
468 <Info className="mr-2 h-4 w-4" />
469 Info
470 </DropdownMenuItem>
471 <DropdownMenuSeparator />
472 <DropdownMenuItem
473 onClick={() => handleDelete([file.path])}
474 className="text-red-600"
475 >
476 <Trash2 className="mr-2 h-4 w-4" />
477 Delete
478 </DropdownMenuItem>
479 </DropdownMenuContent>
480 </DropdownMenu>
481 </TableCell>
482 </TableRow>
483 )
484 })
485 )}
486 </TableBody>
487 </Table>
488 </div>
489
490 {/* Rename Dialog */}
491 <Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
492 <DialogContent>
493 <DialogHeader>
494 <DialogTitle>Rename {currentItem?.type === "dir" ? "Folder" : "File"}</DialogTitle>
495 </DialogHeader>
496 <div className="space-y-4">
497 <div>
498 <Label htmlFor="newName">New Name</Label>
499 <Input
500 id="newName"
501 value={newName}
502 onChange={(e) => setNewName(e.target.value)}
503 onKeyDown={(e) => {
504 if (e.key === "Enter") {
505 handleRename()
506 }
507 }}
508 placeholder="Enter new name"
509 />
510 </div>
511 <div className="flex justify-end gap-2">
512 <Button variant="outline" onClick={() => setRenameDialogOpen(false)}>
513 Cancel
514 </Button>
515 <Button onClick={handleRename} disabled={!newName.trim()}>
516 Rename
517 </Button>
518 </div>
519 </div>
520 </DialogContent>
521 </Dialog>
522
523 {/* Info Dialog */}
524 <Dialog open={infoDialogOpen} onOpenChange={setInfoDialogOpen}>
525 <DialogContent className="max-w-md">
526 <DialogHeader>
527 <DialogTitle className="flex items-center gap-2">
528 {currentItem?.type === "dir" ? (
529 <Folder className="h-5 w-5 text-blue-500" />
530 ) : (
531 <File className="h-5 w-5 text-gray-500" />
532 )}
533 {currentItem?.type === "dir" ? "Folder" : "File"} Information
534 </DialogTitle>
535 </DialogHeader>
536 {currentItem && (
537 <div className="space-y-4">
538 <div className="space-y-3">
539 <div>
540 <Label className="text-sm font-medium text-muted-foreground">Name</Label>
541 <p className="text-sm break-words">{currentItem.path.split('/').pop()}</p>
542 </div>
543 <div>
544 <Label className="text-sm font-medium text-muted-foreground">Size</Label>
545 <p className="text-sm">{formatFileSize(currentItem.size || 0)}</p>
546 </div>
547 <div>
548 <Label className="text-sm font-medium text-muted-foreground">Modified</Label>
549 <p className="text-sm">{formatDateTime(currentItem.lastmod)}</p>
550 </div>
551 <div>
552 <Label className="text-sm font-medium text-muted-foreground">Modified By</Label>
553 <p className="text-sm">{currentItem.author}</p>
554 </div>
555 <div>
556 <Label className="text-sm font-medium text-muted-foreground">Type</Label>
557 <p className="text-sm capitalize">{currentItem.type}</p>
558 </div>
559 <div>
560 <Label className="text-sm font-medium text-muted-foreground">Path</Label>
561 <p className="text-sm font-mono text-xs">{currentItem.path}</p>
562 </div>
563 </div>
564 <div className="flex justify-end">
565 <Button variant="outline" onClick={() => setInfoDialogOpen(false)}>
566 Close
567 </Button>
568 </div>
569 </div>
570 )}
571 </DialogContent>
572 </Dialog>
573
574 {/* Move Dialog */}
575 <V2MoveDialog
576 open={moveDialogOpen}
577 onOpenChange={setMoveDialogOpen}
578 selectedCount={selectedFiles.size}
579 onMove={handleMove}
580 />
581
582 <input
583 ref={fileInputRef}
584 type="file"
585 multiple
586 className="hidden"
587 onChange={handleFileUpload}
588 />
589 </div>
590 )
591} \ No newline at end of file
diff --git a/frontend/components/v2/V2DirectoryView.tsx b/frontend/components/v2/V2DirectoryView.tsx
index 03532e8..97fa9a8 100644
--- a/frontend/components/v2/V2DirectoryView.tsx
+++ b/frontend/components/v2/V2DirectoryView.tsx
@@ -1,694 +1,55 @@
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" 1import { DriveLsEntry } from "@/lib/drive_types"
38import { UPLOAD_MAX_FILE_SIZE, UPLOAD_MAX_FILES } from "@/lib/constants" 2import { V2DirectoryClient } from "./V2DirectoryClient"
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 3
65interface V2DirectoryViewProps { 4interface V2DirectoryViewProps {
66 path: string 5 path: string
6 files: DriveLsEntry[]
67} 7}
68 8
69export function V2DirectoryView({ path }: V2DirectoryViewProps) { 9// Generate breadcrumbs from path
70 const [files, setFiles] = useState<DriveLsEntry[]>([]) 10function generateBreadcrumbs(currentPath: string) {
71 const [loading, setLoading] = useState(true) 11 if (currentPath === '/') {
72 const [error, setError] = useState<string | null>(null) 12 return [{ name: 'Root', path: '/v2' }]
73 const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set()) 13 }
74 const [renameDialogOpen, setRenameDialogOpen] = useState(false) 14
75 const [infoDialogOpen, setInfoDialogOpen] = useState(false) 15 const parts = currentPath.split('/').filter(Boolean)
76 const [moveDialogOpen, setMoveDialogOpen] = useState(false) 16 const breadcrumbs = [{ name: 'Root', path: '/v2' }]
77 const [currentItem, setCurrentItem] = useState<DriveLsEntry | null>(null) 17
78 const [newName, setNewName] = useState("") 18 let accumulatedPath = ''
79 const fileInputRef = useRef<HTMLInputElement>(null) 19 parts.forEach((part, index) => {
80 const [uploading, setUploading] = useState(false) 20 accumulatedPath += '/' + part
81 21 breadcrumbs.push({
82 const [isLoggedIn, setIsLoggedIn] = useState(true) // Mock logged in state 22 name: decodeURIComponent(part), // Decode URL encoded characters
83 23 path: '/v2' + accumulatedPath
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 }) 24 })
334 } 25 })
335 26
336 const handleLogin = () => { 27 return breadcrumbs
337 // Redirect to external auth page (configured via env var) 28}
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 29
348 const handleMove = async (destinationPath: string) => { 30// Sort files: directories first, then files, all alphabetically
349 // TODO: Implement actual move API calls 31function sortFiles(files: DriveLsEntry[]): DriveLsEntry[] {
350 console.log('Moving files:', Array.from(selectedFiles), 'to:', destinationPath) 32 return [...files].sort((a, b) => {
351 setSelectedFiles(new Set()) 33 // Directories first, then files
352 setMoveDialogOpen(false) 34 if (a.type === "dir" && b.type === "file") return -1;
35 if (a.type === "file" && b.type === "dir") return 1;
353 36
354 toast({ 37 // Both same type, sort alphabetically by name (case-insensitive)
355 title: "Moved successfully", 38 const aName = a.path.split('/').pop() || a.path;
356 description: `${selectedFiles.size} item(s) moved to ${destinationPath}`, 39 const bName = b.path.split('/').pop() || b.path;
357 }) 40 return aName.toLowerCase().localeCompare(bName.toLowerCase());
358 41 });
359 // Refresh directory after move 42}
360 await refreshDirectory()
361 }
362 43
44export function V2DirectoryView({ path, files }: V2DirectoryViewProps) {
45 const sortedFiles = sortFiles(files)
46 const breadcrumbs = generateBreadcrumbs(path)
47
363 return ( 48 return (
364 <div className="container mx-auto p-6 space-y-6"> 49 <V2DirectoryClient
365 {/* Header with Breadcrumbs */} 50 path={path}
366 <div className="flex items-center justify-between"> 51 files={sortedFiles}
367 <div className="flex items-center gap-4"> 52 breadcrumbs={breadcrumbs}
368 <div className="flex items-center gap-2"> 53 />
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 ) 54 )
694} \ No newline at end of file 55} \ No newline at end of file