diff options
| -rw-r--r-- | frontend/app/v2/[...path]/page.tsx | 6 | ||||
| -rw-r--r-- | frontend/app/v2/page.tsx | 6 | ||||
| -rw-r--r-- | frontend/components/v2/V2DirectoryClient.tsx | 591 | ||||
| -rw-r--r-- | frontend/components/v2/V2DirectoryView.tsx | 723 |
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 @@ | |||
| 1 | import { V2DirectoryView } from "@/components/v2/V2DirectoryView" | 1 | import { V2DirectoryView } from "@/components/v2/V2DirectoryView" |
| 2 | import { Drive_ls } from "@/lib/drive_server" | ||
| 2 | 3 | ||
| 3 | export default async function V2DirectoryPage({ | 4 | export 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 @@ | |||
| 1 | import { V2DirectoryView } from "@/components/v2/V2DirectoryView" | 1 | import { V2DirectoryView } from "@/components/v2/V2DirectoryView" |
| 2 | import { Drive_ls } from "@/lib/drive_server" | ||
| 2 | 3 | ||
| 3 | export default function V2RootPage() { | 4 | export 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 | |||
| 3 | import type React from "react" | ||
| 4 | import { useState, useRef } from "react" | ||
| 5 | import Link from "next/link" | ||
| 6 | import { | ||
| 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" | ||
| 21 | import { Button } from "@/components/ui/button" | ||
| 22 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" | ||
| 23 | import { Progress } from "@/components/ui/progress" | ||
| 24 | import { | ||
| 25 | DropdownMenu, | ||
| 26 | DropdownMenuContent, | ||
| 27 | DropdownMenuItem, | ||
| 28 | DropdownMenuTrigger, | ||
| 29 | DropdownMenuSeparator, | ||
| 30 | } from "@/components/ui/dropdown-menu" | ||
| 31 | import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" | ||
| 32 | import { Input } from "@/components/ui/input" | ||
| 33 | import { Label } from "@/components/ui/label" | ||
| 34 | import { Checkbox } from "@/components/ui/checkbox" | ||
| 35 | import { toast } from "@/hooks/use-toast" | ||
| 36 | import { DriveLsEntry } from "@/lib/drive_types" | ||
| 37 | import { UPLOAD_MAX_FILE_SIZE, UPLOAD_MAX_FILES } from "@/lib/constants" | ||
| 38 | import { V2MoveDialog } from "./V2MoveDialog" | ||
| 39 | |||
| 40 | function 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 | |||
| 48 | function formatDate(timestamp: number): string { | ||
| 49 | return new Date(timestamp * 1000).toISOString().split('T')[0] | ||
| 50 | } | ||
| 51 | |||
| 52 | function 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 | |||
| 64 | interface Breadcrumb { | ||
| 65 | name: string | ||
| 66 | path: string | ||
| 67 | } | ||
| 68 | |||
| 69 | interface V2DirectoryClientProps { | ||
| 70 | path: string | ||
| 71 | files: DriveLsEntry[] | ||
| 72 | breadcrumbs: Breadcrumb[] | ||
| 73 | } | ||
| 74 | |||
| 75 | export 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 | |||
| 3 | import type React from "react" | ||
| 4 | import { useState, useRef, useEffect } from "react" | ||
| 5 | import Link from "next/link" | ||
| 6 | import { | ||
| 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" | ||
| 22 | import { Button } from "@/components/ui/button" | ||
| 23 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" | ||
| 24 | import { Progress } from "@/components/ui/progress" | ||
| 25 | import { | ||
| 26 | DropdownMenu, | ||
| 27 | DropdownMenuContent, | ||
| 28 | DropdownMenuItem, | ||
| 29 | DropdownMenuTrigger, | ||
| 30 | DropdownMenuSeparator, | ||
| 31 | } from "@/components/ui/dropdown-menu" | ||
| 32 | import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" | ||
| 33 | import { Input } from "@/components/ui/input" | ||
| 34 | import { Label } from "@/components/ui/label" | ||
| 35 | import { Checkbox } from "@/components/ui/checkbox" | ||
| 36 | import { toast } from "@/hooks/use-toast" | ||
| 37 | import { DriveLsEntry } from "@/lib/drive_types" | 1 | import { DriveLsEntry } from "@/lib/drive_types" |
| 38 | import { UPLOAD_MAX_FILE_SIZE, UPLOAD_MAX_FILES } from "@/lib/constants" | 2 | import { V2DirectoryClient } from "./V2DirectoryClient" |
| 39 | import { V2MoveDialog } from "./V2MoveDialog" | ||
| 40 | |||
| 41 | function 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 | |||
| 49 | function formatDate(timestamp: number): string { | ||
| 50 | return new Date(timestamp * 1000).toISOString().split('T')[0] | ||
| 51 | } | ||
| 52 | |||
| 53 | function 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 | ||
| 65 | interface V2DirectoryViewProps { | 4 | interface V2DirectoryViewProps { |
| 66 | path: string | 5 | path: string |
| 6 | files: DriveLsEntry[] | ||
| 67 | } | 7 | } |
| 68 | 8 | ||
| 69 | export function V2DirectoryView({ path }: V2DirectoryViewProps) { | 9 | // Generate breadcrumbs from path |
| 70 | const [files, setFiles] = useState<DriveLsEntry[]>([]) | 10 | function 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 | 31 | function 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 | ||
| 44 | export 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 |
