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