diff options
Diffstat (limited to 'frontend')
| -rw-r--r-- | frontend/app/api/fs/route.ts | 19 | ||||
| -rw-r--r-- | frontend/app/api/log/route.ts | 13 | ||||
| -rw-r--r-- | frontend/app/api/tree/route.ts | 13 | ||||
| -rw-r--r-- | frontend/app/drive/[...path]/page.tsx (renamed from frontend/app/v2/[...path]/page.tsx) | 6 | ||||
| -rw-r--r-- | frontend/app/drive/page.tsx | 7 | ||||
| -rw-r--r-- | frontend/app/page.tsx | 4 | ||||
| -rw-r--r-- | frontend/app/v2/page.tsx | 7 | ||||
| -rw-r--r-- | frontend/components/drive/DriveDirectoryClient.tsx (renamed from frontend/components/v2/V2DirectoryClient.tsx) | 12 | ||||
| -rw-r--r-- | frontend/components/drive/DriveDirectoryView.tsx (renamed from frontend/components/v2/V2DirectoryView.tsx) | 14 | ||||
| -rw-r--r-- | frontend/components/drive/DriveMoveDialog.tsx (renamed from frontend/components/v2/V2MoveDialog.tsx) | 4 | ||||
| -rw-r--r-- | frontend/file-drive.tsx | 805 | ||||
| -rw-r--r-- | frontend/history-view.tsx | 252 |
12 files changed, 27 insertions, 1129 deletions
diff --git a/frontend/app/api/fs/route.ts b/frontend/app/api/fs/route.ts deleted file mode 100644 index 61d0f8a..0000000 --- a/frontend/app/api/fs/route.ts +++ /dev/null | |||
| @@ -1,19 +0,0 @@ | |||
| 1 | import { NextResponse } from 'next/server' | ||
| 2 | import { Drive_ls } from '@/lib/drive_server' | ||
| 3 | |||
| 4 | // GET /api/fs - Get root directory listing | ||
| 5 | export async function GET() { | ||
| 6 | try { | ||
| 7 | // Get root directory listing using Drive_ls (non-recursive) | ||
| 8 | const entries = await Drive_ls('/', false) | ||
| 9 | |||
| 10 | return NextResponse.json(entries) | ||
| 11 | |||
| 12 | } catch (error) { | ||
| 13 | console.error('GET fs root error:', error) | ||
| 14 | return NextResponse.json( | ||
| 15 | { error: error instanceof Error ? error.message : 'Internal server error' }, | ||
| 16 | { status: 500 } | ||
| 17 | ) | ||
| 18 | } | ||
| 19 | } \ No newline at end of file | ||
diff --git a/frontend/app/api/log/route.ts b/frontend/app/api/log/route.ts deleted file mode 100644 index a316ce7..0000000 --- a/frontend/app/api/log/route.ts +++ /dev/null | |||
| @@ -1,13 +0,0 @@ | |||
| 1 | import { NextResponse } from 'next/server' | ||
| 2 | import { Drive_log } from '@/lib/drive_server' | ||
| 3 | |||
| 4 | export async function GET() { | ||
| 5 | try { | ||
| 6 | const logEntries = await Drive_log() | ||
| 7 | |||
| 8 | return NextResponse.json(logEntries) | ||
| 9 | } catch (error) { | ||
| 10 | console.error('Error getting log entries:', error) | ||
| 11 | throw error | ||
| 12 | } | ||
| 13 | } \ No newline at end of file | ||
diff --git a/frontend/app/api/tree/route.ts b/frontend/app/api/tree/route.ts deleted file mode 100644 index ece8122..0000000 --- a/frontend/app/api/tree/route.ts +++ /dev/null | |||
| @@ -1,13 +0,0 @@ | |||
| 1 | import { NextResponse } from 'next/server' | ||
| 2 | import { Drive_tree } from '@/lib/drive_server' | ||
| 3 | |||
| 4 | export async function GET() { | ||
| 5 | try { | ||
| 6 | const treeResponse = await Drive_tree() | ||
| 7 | |||
| 8 | return NextResponse.json(treeResponse) | ||
| 9 | } catch (error) { | ||
| 10 | console.error('Error building tree:', error) | ||
| 11 | throw error | ||
| 12 | } | ||
| 13 | } \ No newline at end of file | ||
diff --git a/frontend/app/v2/[...path]/page.tsx b/frontend/app/drive/[...path]/page.tsx index 3b4cbb8..b0c6d7d 100644 --- a/frontend/app/v2/[...path]/page.tsx +++ b/frontend/app/drive/[...path]/page.tsx | |||
| @@ -1,7 +1,7 @@ | |||
| 1 | import { V2DirectoryView } from "@/components/v2/V2DirectoryView" | 1 | import { DriveDirectoryView } from "@/components/drive/DriveDirectoryView" |
| 2 | import { Drive_ls } from "@/lib/drive_server" | 2 | import { Drive_ls } from "@/lib/drive_server" |
| 3 | 3 | ||
| 4 | export default async function V2DirectoryPage({ | 4 | export default async function DriveDirectoryPage({ |
| 5 | params, | 5 | params, |
| 6 | }: { | 6 | }: { |
| 7 | params: Promise<{ path: string[] }> | 7 | params: Promise<{ path: string[] }> |
| @@ -10,5 +10,5 @@ export default async function V2DirectoryPage({ | |||
| 10 | const currentPath = '/' + (pathSegments?.join('/') || '') | 10 | const currentPath = '/' + (pathSegments?.join('/') || '') |
| 11 | 11 | ||
| 12 | const files = await Drive_ls(currentPath, false) | 12 | const files = await Drive_ls(currentPath, false) |
| 13 | return <V2DirectoryView path={currentPath} files={files} /> | 13 | return <DriveDirectoryView path={currentPath} files={files} /> |
| 14 | } \ No newline at end of file | 14 | } \ No newline at end of file |
diff --git a/frontend/app/drive/page.tsx b/frontend/app/drive/page.tsx new file mode 100644 index 0000000..0e3fd0c --- /dev/null +++ b/frontend/app/drive/page.tsx | |||
| @@ -0,0 +1,7 @@ | |||
| 1 | import { DriveDirectoryView } from "@/components/drive/DriveDirectoryView" | ||
| 2 | import { Drive_ls } from "@/lib/drive_server" | ||
| 3 | |||
| 4 | export default async function DriveRootPage() { | ||
| 5 | const files = await Drive_ls("/", false) | ||
| 6 | return <DriveDirectoryView path="/" files={files} /> | ||
| 7 | } \ No newline at end of file | ||
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index e2b6a80..e1c6eed 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx | |||
| @@ -1,5 +1,5 @@ | |||
| 1 | import FileDrive from "../file-drive" | 1 | import { redirect } from 'next/navigation' |
| 2 | 2 | ||
| 3 | export default function Page() { | 3 | export default function Page() { |
| 4 | return <FileDrive /> | 4 | redirect('/drive') |
| 5 | } | 5 | } |
diff --git a/frontend/app/v2/page.tsx b/frontend/app/v2/page.tsx deleted file mode 100644 index 09418a2..0000000 --- a/frontend/app/v2/page.tsx +++ /dev/null | |||
| @@ -1,7 +0,0 @@ | |||
| 1 | import { V2DirectoryView } from "@/components/v2/V2DirectoryView" | ||
| 2 | import { Drive_ls } from "@/lib/drive_server" | ||
| 3 | |||
| 4 | export default async function V2RootPage() { | ||
| 5 | const files = await Drive_ls("/", false) | ||
| 6 | return <V2DirectoryView path="/" files={files} /> | ||
| 7 | } \ No newline at end of file | ||
diff --git a/frontend/components/v2/V2DirectoryClient.tsx b/frontend/components/drive/DriveDirectoryClient.tsx index 0d9a63a..548773a 100644 --- a/frontend/components/v2/V2DirectoryClient.tsx +++ b/frontend/components/drive/DriveDirectoryClient.tsx | |||
| @@ -35,7 +35,7 @@ import { Checkbox } from "@/components/ui/checkbox" | |||
| 35 | import { toast } from "@/hooks/use-toast" | 35 | import { toast } from "@/hooks/use-toast" |
| 36 | import { DriveLsEntry } from "@/lib/drive_types" | 36 | import { DriveLsEntry } from "@/lib/drive_types" |
| 37 | import { UPLOAD_MAX_FILE_SIZE, UPLOAD_MAX_FILES } from "@/lib/constants" | 37 | import { UPLOAD_MAX_FILE_SIZE, UPLOAD_MAX_FILES } from "@/lib/constants" |
| 38 | import { V2MoveDialog } from "./V2MoveDialog" | 38 | import { DriveMoveDialog } from "./DriveMoveDialog" |
| 39 | 39 | ||
| 40 | function formatFileSize(bytes: number): string { | 40 | function formatFileSize(bytes: number): string { |
| 41 | if (bytes === 0) return "0 Bytes" | 41 | if (bytes === 0) return "0 Bytes" |
| @@ -66,13 +66,13 @@ interface Breadcrumb { | |||
| 66 | path: string | 66 | path: string |
| 67 | } | 67 | } |
| 68 | 68 | ||
| 69 | interface V2DirectoryClientProps { | 69 | interface DriveDirectoryClientProps { |
| 70 | path: string | 70 | path: string |
| 71 | files: DriveLsEntry[] | 71 | files: DriveLsEntry[] |
| 72 | breadcrumbs: Breadcrumb[] | 72 | breadcrumbs: Breadcrumb[] |
| 73 | } | 73 | } |
| 74 | 74 | ||
| 75 | export function V2DirectoryClient({ path, files, breadcrumbs }: V2DirectoryClientProps) { | 75 | export function DriveDirectoryClient({ path, files, breadcrumbs }: DriveDirectoryClientProps) { |
| 76 | const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set()) | 76 | const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set()) |
| 77 | const [renameDialogOpen, setRenameDialogOpen] = useState(false) | 77 | const [renameDialogOpen, setRenameDialogOpen] = useState(false) |
| 78 | const [infoDialogOpen, setInfoDialogOpen] = useState(false) | 78 | const [infoDialogOpen, setInfoDialogOpen] = useState(false) |
| @@ -287,7 +287,7 @@ export function V2DirectoryClient({ path, files, breadcrumbs }: V2DirectoryClien | |||
| 287 | <div className="flex items-center gap-4"> | 287 | <div className="flex items-center gap-4"> |
| 288 | <div className="flex items-center gap-2"> | 288 | <div className="flex items-center gap-2"> |
| 289 | <HardDrive className="h-6 w-6" /> | 289 | <HardDrive className="h-6 w-6" /> |
| 290 | <h1 className="text-2xl font-bold">Drive V2</h1> | 290 | <h1 className="text-2xl font-bold">Drive</h1> |
| 291 | </div> | 291 | </div> |
| 292 | 292 | ||
| 293 | {/* Breadcrumbs */} | 293 | {/* Breadcrumbs */} |
| @@ -421,7 +421,7 @@ export function V2DirectoryClient({ path, files, breadcrumbs }: V2DirectoryClien | |||
| 421 | <> | 421 | <> |
| 422 | <Folder className="h-4 w-4 text-blue-500" /> | 422 | <Folder className="h-4 w-4 text-blue-500" /> |
| 423 | <Link | 423 | <Link |
| 424 | href={`/v2${file.path}`} | 424 | href={`/drive${file.path}`} |
| 425 | className="text-blue-600 hover:text-blue-800 hover:underline cursor-pointer" | 425 | className="text-blue-600 hover:text-blue-800 hover:underline cursor-pointer" |
| 426 | > | 426 | > |
| 427 | {fileName} | 427 | {fileName} |
| @@ -572,7 +572,7 @@ export function V2DirectoryClient({ path, files, breadcrumbs }: V2DirectoryClien | |||
| 572 | </Dialog> | 572 | </Dialog> |
| 573 | 573 | ||
| 574 | {/* Move Dialog */} | 574 | {/* Move Dialog */} |
| 575 | <V2MoveDialog | 575 | <DriveMoveDialog |
| 576 | open={moveDialogOpen} | 576 | open={moveDialogOpen} |
| 577 | onOpenChange={setMoveDialogOpen} | 577 | onOpenChange={setMoveDialogOpen} |
| 578 | selectedCount={selectedFiles.size} | 578 | selectedCount={selectedFiles.size} |
diff --git a/frontend/components/v2/V2DirectoryView.tsx b/frontend/components/drive/DriveDirectoryView.tsx index 97fa9a8..20ed9d8 100644 --- a/frontend/components/v2/V2DirectoryView.tsx +++ b/frontend/components/drive/DriveDirectoryView.tsx | |||
| @@ -1,7 +1,7 @@ | |||
| 1 | import { DriveLsEntry } from "@/lib/drive_types" | 1 | import { DriveLsEntry } from "@/lib/drive_types" |
| 2 | import { V2DirectoryClient } from "./V2DirectoryClient" | 2 | import { DriveDirectoryClient } from "./DriveDirectoryClient" |
| 3 | 3 | ||
| 4 | interface V2DirectoryViewProps { | 4 | interface DriveDirectoryViewProps { |
| 5 | path: string | 5 | path: string |
| 6 | files: DriveLsEntry[] | 6 | files: DriveLsEntry[] |
| 7 | } | 7 | } |
| @@ -9,18 +9,18 @@ interface V2DirectoryViewProps { | |||
| 9 | // Generate breadcrumbs from path | 9 | // Generate breadcrumbs from path |
| 10 | function generateBreadcrumbs(currentPath: string) { | 10 | function generateBreadcrumbs(currentPath: string) { |
| 11 | if (currentPath === '/') { | 11 | if (currentPath === '/') { |
| 12 | return [{ name: 'Root', path: '/v2' }] | 12 | return [{ name: 'Root', path: '/drive' }] |
| 13 | } | 13 | } |
| 14 | 14 | ||
| 15 | const parts = currentPath.split('/').filter(Boolean) | 15 | const parts = currentPath.split('/').filter(Boolean) |
| 16 | const breadcrumbs = [{ name: 'Root', path: '/v2' }] | 16 | const breadcrumbs = [{ name: 'Root', path: '/drive' }] |
| 17 | 17 | ||
| 18 | let accumulatedPath = '' | 18 | let accumulatedPath = '' |
| 19 | parts.forEach((part, index) => { | 19 | parts.forEach((part, index) => { |
| 20 | accumulatedPath += '/' + part | 20 | accumulatedPath += '/' + part |
| 21 | breadcrumbs.push({ | 21 | breadcrumbs.push({ |
| 22 | name: decodeURIComponent(part), // Decode URL encoded characters | 22 | name: decodeURIComponent(part), // Decode URL encoded characters |
| 23 | path: '/v2' + accumulatedPath | 23 | path: '/drive' + accumulatedPath |
| 24 | }) | 24 | }) |
| 25 | }) | 25 | }) |
| 26 | 26 | ||
| @@ -41,12 +41,12 @@ function sortFiles(files: DriveLsEntry[]): DriveLsEntry[] { | |||
| 41 | }); | 41 | }); |
| 42 | } | 42 | } |
| 43 | 43 | ||
| 44 | export function V2DirectoryView({ path, files }: V2DirectoryViewProps) { | 44 | export function DriveDirectoryView({ path, files }: DriveDirectoryViewProps) { |
| 45 | const sortedFiles = sortFiles(files) | 45 | const sortedFiles = sortFiles(files) |
| 46 | const breadcrumbs = generateBreadcrumbs(path) | 46 | const breadcrumbs = generateBreadcrumbs(path) |
| 47 | 47 | ||
| 48 | return ( | 48 | return ( |
| 49 | <V2DirectoryClient | 49 | <DriveDirectoryClient |
| 50 | path={path} | 50 | path={path} |
| 51 | files={sortedFiles} | 51 | files={sortedFiles} |
| 52 | breadcrumbs={breadcrumbs} | 52 | breadcrumbs={breadcrumbs} |
diff --git a/frontend/components/v2/V2MoveDialog.tsx b/frontend/components/drive/DriveMoveDialog.tsx index 7cedde0..d00f860 100644 --- a/frontend/components/v2/V2MoveDialog.tsx +++ b/frontend/components/drive/DriveMoveDialog.tsx | |||
| @@ -9,14 +9,14 @@ import { ScrollArea } from "@/components/ui/scroll-area" | |||
| 9 | import { toast } from "@/hooks/use-toast" | 9 | import { toast } from "@/hooks/use-toast" |
| 10 | import { DriveLsEntry } from "@/lib/drive_types" | 10 | import { DriveLsEntry } from "@/lib/drive_types" |
| 11 | 11 | ||
| 12 | interface V2MoveDialogProps { | 12 | interface DriveMoveDialogProps { |
| 13 | open: boolean | 13 | open: boolean |
| 14 | onOpenChange: (open: boolean) => void | 14 | onOpenChange: (open: boolean) => void |
| 15 | selectedCount: number | 15 | selectedCount: number |
| 16 | onMove: (destinationPath: string) => void | 16 | onMove: (destinationPath: string) => void |
| 17 | } | 17 | } |
| 18 | 18 | ||
| 19 | export function V2MoveDialog({ open, onOpenChange, selectedCount, onMove }: V2MoveDialogProps) { | 19 | export function DriveMoveDialog({ open, onOpenChange, selectedCount, onMove }: DriveMoveDialogProps) { |
| 20 | const [directories, setDirectories] = useState<DriveLsEntry[]>([]) | 20 | const [directories, setDirectories] = useState<DriveLsEntry[]>([]) |
| 21 | const [filteredDirectories, setFilteredDirectories] = useState<DriveLsEntry[]>([]) | 21 | const [filteredDirectories, setFilteredDirectories] = useState<DriveLsEntry[]>([]) |
| 22 | const [searchQuery, setSearchQuery] = useState("") | 22 | const [searchQuery, setSearchQuery] = useState("") |
diff --git a/frontend/file-drive.tsx b/frontend/file-drive.tsx deleted file mode 100644 index 123f088..0000000 --- a/frontend/file-drive.tsx +++ /dev/null | |||
| @@ -1,805 +0,0 @@ | |||
| 1 | "use client" | ||
| 2 | |||
| 3 | import type React from "react" | ||
| 4 | |||
| 5 | import { useState, useRef, useEffect } from "react" | ||
| 6 | import { | ||
| 7 | ChevronRight, | ||
| 8 | ChevronDown, | ||
| 9 | File, | ||
| 10 | Folder, | ||
| 11 | Upload, | ||
| 12 | Trash2, | ||
| 13 | Move, | ||
| 14 | MoreHorizontal, | ||
| 15 | HardDrive, | ||
| 16 | Edit, | ||
| 17 | Link, | ||
| 18 | Info, | ||
| 19 | LogIn, | ||
| 20 | LogOut, | ||
| 21 | HistoryIcon, | ||
| 22 | } from "lucide-react" | ||
| 23 | import { Button } from "@/components/ui/button" | ||
| 24 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" | ||
| 25 | import { Progress } from "@/components/ui/progress" | ||
| 26 | import { | ||
| 27 | DropdownMenu, | ||
| 28 | DropdownMenuContent, | ||
| 29 | DropdownMenuItem, | ||
| 30 | DropdownMenuTrigger, | ||
| 31 | DropdownMenuSeparator, | ||
| 32 | } from "@/components/ui/dropdown-menu" | ||
| 33 | import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" | ||
| 34 | import { Input } from "@/components/ui/input" | ||
| 35 | import { Label } from "@/components/ui/label" | ||
| 36 | import { Checkbox } from "@/components/ui/checkbox" | ||
| 37 | import { toast } from "@/hooks/use-toast" | ||
| 38 | import HistoryView from "./history-view" | ||
| 39 | import { DriveTreeResponse, DriveTreeNode } from "@/lib/drive_types" | ||
| 40 | import { UPLOAD_MAX_FILE_SIZE, UPLOAD_MAX_FILES } from "@/lib/constants" | ||
| 41 | |||
| 42 | |||
| 43 | function formatFileSize(bytes: number): string { | ||
| 44 | if (bytes === 0) return "0 Bytes" | ||
| 45 | const k = 1024 | ||
| 46 | const sizes = ["Bytes", "KB", "MB", "GB"] | ||
| 47 | const i = Math.floor(Math.log(bytes) / Math.log(k)) | ||
| 48 | return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i] | ||
| 49 | } | ||
| 50 | |||
| 51 | function calculateTotalSize(items: DriveTreeNode[]): number { | ||
| 52 | return items.reduce((total, item) => { | ||
| 53 | if (item.type === "dir" && item.children) { | ||
| 54 | return total + calculateTotalSize(item.children) | ||
| 55 | } | ||
| 56 | return total + (item.size || 0) | ||
| 57 | }, 0) | ||
| 58 | } | ||
| 59 | |||
| 60 | // Fetch data from /api/tree endpoint | ||
| 61 | async function fetchDriveTree(): Promise<DriveTreeResponse> { | ||
| 62 | const response = await fetch('/api/tree') | ||
| 63 | if (!response.ok) { | ||
| 64 | throw new Error(`Failed to fetch drive tree: ${response.statusText}`) | ||
| 65 | } | ||
| 66 | return await response.json() | ||
| 67 | } | ||
| 68 | |||
| 69 | // Convert UNIX timestamp to date string | ||
| 70 | function formatDate(timestamp: number): string { | ||
| 71 | return new Date(timestamp * 1000).toISOString().split('T')[0] // YYYY-MM-DD | ||
| 72 | } | ||
| 73 | |||
| 74 | // Convert UNIX timestamp to date and time string | ||
| 75 | function formatDateTime(timestamp: number): string { | ||
| 76 | const date = new Date(timestamp * 1000) | ||
| 77 | const dateStr = date.toISOString().split('T')[0] // YYYY-MM-DD | ||
| 78 | const timeStr = date.toLocaleTimeString('en-US', { | ||
| 79 | hour12: false, | ||
| 80 | hour: '2-digit', | ||
| 81 | minute: '2-digit', | ||
| 82 | second: '2-digit' | ||
| 83 | }) | ||
| 84 | return `${dateStr} at ${timeStr}` | ||
| 85 | } | ||
| 86 | |||
| 87 | export default function FileDrive() { | ||
| 88 | const [files, setFiles] = useState<DriveTreeNode[]>([]) | ||
| 89 | const [loading, setLoading] = useState(true) | ||
| 90 | const [error, setError] = useState<string | null>(null) | ||
| 91 | const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set()) | ||
| 92 | const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set()) | ||
| 93 | const [renameDialogOpen, setRenameDialogOpen] = useState(false) | ||
| 94 | const [infoDialogOpen, setInfoDialogOpen] = useState(false) | ||
| 95 | const [currentItem, setCurrentItem] = useState<DriveTreeNode | null>(null) | ||
| 96 | const [newName, setNewName] = useState("") | ||
| 97 | const fileInputRef = useRef<HTMLInputElement>(null) | ||
| 98 | const [uploading, setUploading] = useState(false) | ||
| 99 | |||
| 100 | const [isLoggedIn, setIsLoggedIn] = useState(true) // Mock logged in state | ||
| 101 | const [currentView, setCurrentView] = useState<"drive" | "history">("drive") | ||
| 102 | const [uploadToFolder, setUploadToFolder] = useState<string | null>(null) | ||
| 103 | |||
| 104 | const maxStorage = 1073741824 // 1GB | ||
| 105 | const usedStorage = 0;//calculateTotalSize(files) | ||
| 106 | const storagePercentage = (usedStorage / maxStorage) * 100 | ||
| 107 | |||
| 108 | // Function to refresh file tree | ||
| 109 | const refreshFileTree = async () => { | ||
| 110 | try { | ||
| 111 | const treeResponse = await fetchDriveTree() | ||
| 112 | setFiles(treeResponse.root) | ||
| 113 | } catch (err) { | ||
| 114 | console.error('Error refreshing file tree:', err) | ||
| 115 | toast({ | ||
| 116 | title: "Failed to refresh", | ||
| 117 | description: "Could not refresh file list after upload", | ||
| 118 | variant: "destructive" | ||
| 119 | }) | ||
| 120 | } | ||
| 121 | } | ||
| 122 | |||
| 123 | // Load drive data on component mount | ||
| 124 | useEffect(() => { | ||
| 125 | async function loadDriveData() { | ||
| 126 | try { | ||
| 127 | setLoading(true) | ||
| 128 | setError(null) | ||
| 129 | const treeResponse = await fetchDriveTree() | ||
| 130 | setFiles(treeResponse.root) | ||
| 131 | } catch (err) { | ||
| 132 | setError(err instanceof Error ? err.message : 'Failed to load drive data') | ||
| 133 | console.error('Error loading drive data:', err) | ||
| 134 | } finally { | ||
| 135 | setLoading(false) | ||
| 136 | } | ||
| 137 | } | ||
| 138 | |||
| 139 | loadDriveData() | ||
| 140 | }, []) | ||
| 141 | |||
| 142 | const toggleFolder = (folderId: string) => { | ||
| 143 | const newExpanded = new Set(expandedFolders) | ||
| 144 | if (newExpanded.has(folderId)) { | ||
| 145 | newExpanded.delete(folderId) | ||
| 146 | } else { | ||
| 147 | newExpanded.add(folderId) | ||
| 148 | } | ||
| 149 | setExpandedFolders(newExpanded) | ||
| 150 | } | ||
| 151 | |||
| 152 | const toggleFileSelection = (fileId: string) => { | ||
| 153 | const newSelected = new Set(selectedFiles) | ||
| 154 | if (newSelected.has(fileId)) { | ||
| 155 | newSelected.delete(fileId) | ||
| 156 | } else { | ||
| 157 | newSelected.add(fileId) | ||
| 158 | } | ||
| 159 | setSelectedFiles(newSelected) | ||
| 160 | } | ||
| 161 | |||
| 162 | const selectAll = () => { | ||
| 163 | const getAllPaths = (items: DriveTreeNode[]): string[] => { | ||
| 164 | const paths: string[] = [] | ||
| 165 | items.forEach((item) => { | ||
| 166 | paths.push(item.path) | ||
| 167 | if (item.children) { | ||
| 168 | paths.push(...getAllPaths(item.children)) | ||
| 169 | } | ||
| 170 | }) | ||
| 171 | return paths | ||
| 172 | } | ||
| 173 | setSelectedFiles(new Set(getAllPaths(files))) | ||
| 174 | } | ||
| 175 | |||
| 176 | const deselectAll = () => { | ||
| 177 | setSelectedFiles(new Set()) | ||
| 178 | } | ||
| 179 | |||
| 180 | const openRenameDialog = (item: DriveTreeNode) => { | ||
| 181 | setCurrentItem(item) | ||
| 182 | setNewName(item.name) | ||
| 183 | setRenameDialogOpen(true) | ||
| 184 | } | ||
| 185 | |||
| 186 | const openInfoDialog = (item: DriveTreeNode) => { | ||
| 187 | setCurrentItem(item) | ||
| 188 | setInfoDialogOpen(true) | ||
| 189 | } | ||
| 190 | |||
| 191 | const copyPermalink = (item: DriveTreeNode) => { | ||
| 192 | const permalink = `${window.location.origin}/drive/file/${item.path}` | ||
| 193 | navigator.clipboard.writeText(permalink).then(() => { | ||
| 194 | toast({ | ||
| 195 | title: "Link copied!", | ||
| 196 | description: "Permalink has been copied to clipboard", | ||
| 197 | }) | ||
| 198 | }) | ||
| 199 | } | ||
| 200 | |||
| 201 | const handleRename = () => { | ||
| 202 | if (currentItem && newName.trim()) { | ||
| 203 | const renameInArray = (items: DriveTreeNode[]): DriveTreeNode[] => { | ||
| 204 | return items.map((item) => { | ||
| 205 | if (item.path === currentItem.path) { | ||
| 206 | return { ...item, name: newName.trim() } | ||
| 207 | } | ||
| 208 | if (item.children) { | ||
| 209 | return { ...item, children: renameInArray(item.children) } | ||
| 210 | } | ||
| 211 | return item | ||
| 212 | }) | ||
| 213 | } | ||
| 214 | setFiles(renameInArray(files)) | ||
| 215 | setRenameDialogOpen(false) | ||
| 216 | setCurrentItem(null) | ||
| 217 | setNewName("") | ||
| 218 | toast({ | ||
| 219 | title: "Renamed successfully", | ||
| 220 | description: `Item renamed to "${newName.trim()}"`, | ||
| 221 | }) | ||
| 222 | } | ||
| 223 | } | ||
| 224 | |||
| 225 | const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => { | ||
| 226 | const uploadedFiles = event.target.files | ||
| 227 | if (!uploadedFiles || uploadedFiles.length === 0) return | ||
| 228 | |||
| 229 | // Validate file count | ||
| 230 | if (uploadedFiles.length > UPLOAD_MAX_FILES) { | ||
| 231 | toast({ | ||
| 232 | title: "Too many files", | ||
| 233 | description: `You can only upload up to ${UPLOAD_MAX_FILES} files at once`, | ||
| 234 | variant: "destructive" | ||
| 235 | }) | ||
| 236 | return | ||
| 237 | } | ||
| 238 | |||
| 239 | // Validate file sizes | ||
| 240 | const oversizedFiles = Array.from(uploadedFiles).filter(file => file.size > UPLOAD_MAX_FILE_SIZE) | ||
| 241 | if (oversizedFiles.length > 0) { | ||
| 242 | toast({ | ||
| 243 | title: "Files too large", | ||
| 244 | description: `Maximum file size is ${formatFileSize(UPLOAD_MAX_FILE_SIZE)}. Found ${oversizedFiles.length} oversized file(s)`, | ||
| 245 | variant: "destructive" | ||
| 246 | }) | ||
| 247 | return | ||
| 248 | } | ||
| 249 | |||
| 250 | setUploading(true) | ||
| 251 | let successCount = 0 | ||
| 252 | let errorCount = 0 | ||
| 253 | |||
| 254 | try { | ||
| 255 | // Upload files sequentially to avoid overwhelming the server | ||
| 256 | for (const file of Array.from(uploadedFiles)) { | ||
| 257 | try { | ||
| 258 | const formData = new FormData() | ||
| 259 | formData.append('file', file) | ||
| 260 | |||
| 261 | const response = await fetch(`/api/fs/${encodeURIComponent(file.name)}`, { | ||
| 262 | method: 'PUT', | ||
| 263 | headers: { | ||
| 264 | 'AUTH': '1' // Development auth header | ||
| 265 | }, | ||
| 266 | body: formData | ||
| 267 | }) | ||
| 268 | |||
| 269 | if (!response.ok) { | ||
| 270 | const error = await response.json() | ||
| 271 | throw new Error(error.error || `Upload failed with status ${response.status}`) | ||
| 272 | } | ||
| 273 | |||
| 274 | successCount++ | ||
| 275 | } catch (error) { | ||
| 276 | console.error(`Failed to upload ${file.name}:`, error) | ||
| 277 | errorCount++ | ||
| 278 | } | ||
| 279 | } | ||
| 280 | |||
| 281 | // Show results | ||
| 282 | if (successCount > 0) { | ||
| 283 | toast({ | ||
| 284 | title: "Upload successful", | ||
| 285 | description: `${successCount} file(s) uploaded successfully${errorCount > 0 ? `, ${errorCount} failed` : ''}` | ||
| 286 | }) | ||
| 287 | |||
| 288 | // Refresh the file tree | ||
| 289 | await refreshFileTree() | ||
| 290 | } | ||
| 291 | |||
| 292 | if (errorCount > 0 && successCount === 0) { | ||
| 293 | toast({ | ||
| 294 | title: "Upload failed", | ||
| 295 | description: `All ${errorCount} file(s) failed to upload`, | ||
| 296 | variant: "destructive" | ||
| 297 | }) | ||
| 298 | } | ||
| 299 | |||
| 300 | } catch (error) { | ||
| 301 | console.error('Upload error:', error) | ||
| 302 | toast({ | ||
| 303 | title: "Upload failed", | ||
| 304 | description: error instanceof Error ? error.message : 'Unknown error occurred', | ||
| 305 | variant: "destructive" | ||
| 306 | }) | ||
| 307 | } finally { | ||
| 308 | setUploading(false) | ||
| 309 | // Reset the input | ||
| 310 | event.target.value = '' | ||
| 311 | } | ||
| 312 | } | ||
| 313 | |||
| 314 | const deleteItems = (itemPaths: string[]) => { | ||
| 315 | const deleteFromArray = (items: DriveTreeNode[]): DriveTreeNode[] => { | ||
| 316 | return items.filter((item) => { | ||
| 317 | if (itemPaths.includes(item.path)) return false | ||
| 318 | if (item.children) { | ||
| 319 | item.children = deleteFromArray(item.children) | ||
| 320 | } | ||
| 321 | return true | ||
| 322 | }) | ||
| 323 | } | ||
| 324 | setFiles(deleteFromArray(files)) | ||
| 325 | // Remove deleted items from selection | ||
| 326 | const newSelected = new Set(selectedFiles) | ||
| 327 | itemPaths.forEach((path) => newSelected.delete(path)) | ||
| 328 | setSelectedFiles(newSelected) | ||
| 329 | } | ||
| 330 | |||
| 331 | const handleLogin = () => { | ||
| 332 | // Redirect to external auth page (configured via env var) | ||
| 333 | const authUrl = process.env.NEXT_PUBLIC_AUTH_URL || "/auth/login" | ||
| 334 | window.location.href = authUrl | ||
| 335 | } | ||
| 336 | |||
| 337 | const handleLogout = () => { | ||
| 338 | // Handle logout (would typically clear tokens, etc.) | ||
| 339 | setIsLoggedIn(false) | ||
| 340 | // Could also redirect to logout endpoint | ||
| 341 | } | ||
| 342 | |||
| 343 | const handleFolderUpload = async (event: React.ChangeEvent<HTMLInputElement>, folderPath: string) => { | ||
| 344 | const uploadedFiles = event.target.files | ||
| 345 | if (!uploadedFiles || uploadedFiles.length === 0) { | ||
| 346 | setUploadToFolder(null) | ||
| 347 | return | ||
| 348 | } | ||
| 349 | |||
| 350 | // Validate file count | ||
| 351 | if (uploadedFiles.length > UPLOAD_MAX_FILES) { | ||
| 352 | toast({ | ||
| 353 | title: "Too many files", | ||
| 354 | description: `You can only upload up to ${UPLOAD_MAX_FILES} files at once`, | ||
| 355 | variant: "destructive" | ||
| 356 | }) | ||
| 357 | setUploadToFolder(null) | ||
| 358 | return | ||
| 359 | } | ||
| 360 | |||
| 361 | // Validate file sizes | ||
| 362 | const oversizedFiles = Array.from(uploadedFiles).filter(file => file.size > UPLOAD_MAX_FILE_SIZE) | ||
| 363 | if (oversizedFiles.length > 0) { | ||
| 364 | toast({ | ||
| 365 | title: "Files too large", | ||
| 366 | description: `Maximum file size is ${formatFileSize(UPLOAD_MAX_FILE_SIZE)}. Found ${oversizedFiles.length} oversized file(s)`, | ||
| 367 | variant: "destructive" | ||
| 368 | }) | ||
| 369 | setUploadToFolder(null) | ||
| 370 | return | ||
| 371 | } | ||
| 372 | |||
| 373 | setUploading(true) | ||
| 374 | let successCount = 0 | ||
| 375 | let errorCount = 0 | ||
| 376 | |||
| 377 | try { | ||
| 378 | // Upload files sequentially to the target folder | ||
| 379 | for (const file of Array.from(uploadedFiles)) { | ||
| 380 | try { | ||
| 381 | const formData = new FormData() | ||
| 382 | formData.append('file', file) | ||
| 383 | |||
| 384 | // Construct the upload path (folder + filename) | ||
| 385 | const uploadPath = `${folderPath.replace(/^\//, '')}/${file.name}` | ||
| 386 | |||
| 387 | const response = await fetch(`/api/fs/${encodeURIComponent(uploadPath)}`, { | ||
| 388 | method: 'PUT', | ||
| 389 | headers: { | ||
| 390 | 'AUTH': '1' // Development auth header | ||
| 391 | }, | ||
| 392 | body: formData | ||
| 393 | }) | ||
| 394 | |||
| 395 | if (!response.ok) { | ||
| 396 | const error = await response.json() | ||
| 397 | throw new Error(error.error || `Upload failed with status ${response.status}`) | ||
| 398 | } | ||
| 399 | |||
| 400 | successCount++ | ||
| 401 | } catch (error) { | ||
| 402 | console.error(`Failed to upload ${file.name} to ${folderPath}:`, error) | ||
| 403 | errorCount++ | ||
| 404 | } | ||
| 405 | } | ||
| 406 | |||
| 407 | // Show results | ||
| 408 | if (successCount > 0) { | ||
| 409 | toast({ | ||
| 410 | title: "Upload successful", | ||
| 411 | description: `${successCount} file(s) uploaded to folder${errorCount > 0 ? `, ${errorCount} failed` : ''}` | ||
| 412 | }) | ||
| 413 | |||
| 414 | // Refresh the file tree | ||
| 415 | await refreshFileTree() | ||
| 416 | } | ||
| 417 | |||
| 418 | if (errorCount > 0 && successCount === 0) { | ||
| 419 | toast({ | ||
| 420 | title: "Upload failed", | ||
| 421 | description: `All ${errorCount} file(s) failed to upload to folder`, | ||
| 422 | variant: "destructive" | ||
| 423 | }) | ||
| 424 | } | ||
| 425 | |||
| 426 | } catch (error) { | ||
| 427 | console.error('Folder upload error:', error) | ||
| 428 | toast({ | ||
| 429 | title: "Upload failed", | ||
| 430 | description: error instanceof Error ? error.message : 'Unknown error occurred', | ||
| 431 | variant: "destructive" | ||
| 432 | }) | ||
| 433 | } finally { | ||
| 434 | setUploading(false) | ||
| 435 | // Reset the input | ||
| 436 | event.target.value = '' | ||
| 437 | setUploadToFolder(null) | ||
| 438 | } | ||
| 439 | } | ||
| 440 | |||
| 441 | const openFolderUpload = (folderPath: string) => { | ||
| 442 | setUploadToFolder(folderPath) | ||
| 443 | // Trigger file input click after state is set | ||
| 444 | setTimeout(() => { | ||
| 445 | const input = document.getElementById(`folder-upload-${folderPath}`) as HTMLInputElement | ||
| 446 | input?.click() | ||
| 447 | }, 0) | ||
| 448 | } | ||
| 449 | |||
| 450 | const renderFileRow = (item: DriveTreeNode, level = 0): React.ReactNode[] => { | ||
| 451 | const isExpanded = expandedFolders.has(item.path) | ||
| 452 | const isSelected = selectedFiles.has(item.path) | ||
| 453 | const rows: React.ReactNode[] = [] | ||
| 454 | |||
| 455 | rows.push( | ||
| 456 | <TableRow | ||
| 457 | key={item.path} | ||
| 458 | className={`hover:bg-muted/50 ${isSelected ? "bg-muted/30" : ""} ${item.type === "dir" ? "cursor-pointer" : ""}`} | ||
| 459 | onClick={item.type === "dir" ? () => toggleFolder(item.path) : undefined} | ||
| 460 | > | ||
| 461 | <TableCell className="w-[40px]" onClick={(e) => e.stopPropagation()}> | ||
| 462 | <Checkbox checked={isSelected} onCheckedChange={() => toggleFileSelection(item.path)} /> | ||
| 463 | </TableCell> | ||
| 464 | <TableCell className="font-medium"> | ||
| 465 | <div className="flex items-center gap-2" style={{ paddingLeft: `${level * 20}px` }}> | ||
| 466 | {item.type === "dir" && ( | ||
| 467 | <Button variant="ghost" size="sm" className="h-4 w-4 p-0"> | ||
| 468 | {isExpanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />} | ||
| 469 | </Button> | ||
| 470 | )} | ||
| 471 | {item.type === "dir" ? ( | ||
| 472 | <Folder className="h-4 w-4 text-blue-500" /> | ||
| 473 | ) : ( | ||
| 474 | <File className="h-4 w-4 text-gray-500" /> | ||
| 475 | )} | ||
| 476 | {item.type === "file" && item.blob ? ( | ||
| 477 | <a | ||
| 478 | href={`/blob/${item.blob}?filename=${encodeURIComponent(item.name)}`} | ||
| 479 | className="text-blue-600 hover:text-blue-800 hover:underline cursor-pointer" | ||
| 480 | target="_blank" | ||
| 481 | rel="noopener noreferrer" | ||
| 482 | > | ||
| 483 | {item.name} | ||
| 484 | </a> | ||
| 485 | ) : ( | ||
| 486 | <span>{item.name}</span> | ||
| 487 | )} | ||
| 488 | </div> | ||
| 489 | </TableCell> | ||
| 490 | <TableCell>{formatFileSize(item.size || 0)}</TableCell> | ||
| 491 | <TableCell>{formatDate(item.lastmod)}</TableCell> | ||
| 492 | <TableCell onClick={(e) => e.stopPropagation()}> | ||
| 493 | <DropdownMenu> | ||
| 494 | <DropdownMenuTrigger asChild> | ||
| 495 | <Button variant="ghost" size="sm" className="h-8 w-8 p-0"> | ||
| 496 | <MoreHorizontal className="h-4 w-4" /> | ||
| 497 | </Button> | ||
| 498 | </DropdownMenuTrigger> | ||
| 499 | <DropdownMenuContent align="end"> | ||
| 500 | {item.type === "dir" && ( | ||
| 501 | <> | ||
| 502 | <DropdownMenuItem | ||
| 503 | onClick={() => openFolderUpload(item.path)} | ||
| 504 | disabled={uploading} | ||
| 505 | > | ||
| 506 | <Upload className="mr-2 h-4 w-4" /> | ||
| 507 | {uploading ? "Uploading..." : "Upload to Folder"} | ||
| 508 | </DropdownMenuItem> | ||
| 509 | <DropdownMenuSeparator /> | ||
| 510 | </> | ||
| 511 | )} | ||
| 512 | <DropdownMenuItem onClick={() => openRenameDialog(item)}> | ||
| 513 | <Edit className="mr-2 h-4 w-4" /> | ||
| 514 | Rename | ||
| 515 | </DropdownMenuItem> | ||
| 516 | <DropdownMenuItem onClick={() => copyPermalink(item)}> | ||
| 517 | <Link className="mr-2 h-4 w-4" /> | ||
| 518 | Copy Permalink | ||
| 519 | </DropdownMenuItem> | ||
| 520 | <DropdownMenuItem onClick={() => openInfoDialog(item)}> | ||
| 521 | <Info className="mr-2 h-4 w-4" /> | ||
| 522 | Info | ||
| 523 | </DropdownMenuItem> | ||
| 524 | <DropdownMenuSeparator /> | ||
| 525 | <DropdownMenuItem | ||
| 526 | onClick={() => { | ||
| 527 | if (selectedFiles.size > 0) { | ||
| 528 | console.log("Moving selected files to:", item.type === "dir" ? item.path : "parent of " + item.path) | ||
| 529 | setSelectedFiles(new Set()) | ||
| 530 | } | ||
| 531 | }} | ||
| 532 | disabled={selectedFiles.size === 0} | ||
| 533 | className={selectedFiles.size === 0 ? "opacity-50 cursor-not-allowed" : ""} | ||
| 534 | > | ||
| 535 | <Move className="mr-2 h-4 w-4" /> | ||
| 536 | Move Here {selectedFiles.size > 0 && `(${selectedFiles.size})`} | ||
| 537 | </DropdownMenuItem> | ||
| 538 | <DropdownMenuItem onClick={() => deleteItems([item.path])} className="text-red-600"> | ||
| 539 | <Trash2 className="mr-2 h-4 w-4" /> | ||
| 540 | Delete | ||
| 541 | </DropdownMenuItem> | ||
| 542 | </DropdownMenuContent> | ||
| 543 | </DropdownMenu> | ||
| 544 | </TableCell> | ||
| 545 | </TableRow>, | ||
| 546 | ) | ||
| 547 | |||
| 548 | if (item.type === "dir" && item.children && isExpanded) { | ||
| 549 | item.children.forEach((child) => { | ||
| 550 | rows.push(...renderFileRow(child, level + 1)) | ||
| 551 | }) | ||
| 552 | } | ||
| 553 | |||
| 554 | return rows | ||
| 555 | } | ||
| 556 | |||
| 557 | return ( | ||
| 558 | <div className="container mx-auto p-6 space-y-6"> | ||
| 559 | {/* Header */} | ||
| 560 | <div className="flex items-center justify-between"> | ||
| 561 | <div className="flex items-center gap-4"> | ||
| 562 | <div className="flex items-center gap-2"> | ||
| 563 | <HardDrive className="h-6 w-6" /> | ||
| 564 | <h1 className="text-2xl font-bold">My Drive</h1> | ||
| 565 | </div> | ||
| 566 | <div className="flex items-center gap-2"> | ||
| 567 | <Button | ||
| 568 | variant={currentView === "drive" ? "default" : "outline"} | ||
| 569 | size="sm" | ||
| 570 | onClick={() => setCurrentView("drive")} | ||
| 571 | > | ||
| 572 | <HardDrive className="mr-2 h-4 w-4" /> | ||
| 573 | Drive | ||
| 574 | </Button> | ||
| 575 | <Button | ||
| 576 | variant={currentView === "history" ? "default" : "outline"} | ||
| 577 | size="sm" | ||
| 578 | onClick={() => setCurrentView("history")} | ||
| 579 | > | ||
| 580 | <HistoryIcon className="mr-2 h-4 w-4" /> | ||
| 581 | History | ||
| 582 | </Button> | ||
| 583 | </div> | ||
| 584 | </div> | ||
| 585 | <div className="flex items-center gap-2"> | ||
| 586 | {currentView === "drive" && ( | ||
| 587 | <Button | ||
| 588 | onClick={() => fileInputRef.current?.click()} | ||
| 589 | disabled={uploading} | ||
| 590 | > | ||
| 591 | <Upload className="mr-2 h-4 w-4" /> | ||
| 592 | {uploading ? "Uploading..." : "Upload Files"} | ||
| 593 | </Button> | ||
| 594 | )} | ||
| 595 | {isLoggedIn ? ( | ||
| 596 | <Button variant="outline" onClick={handleLogout}> | ||
| 597 | <LogOut className="mr-2 h-4 w-4" /> | ||
| 598 | Logout | ||
| 599 | </Button> | ||
| 600 | ) : ( | ||
| 601 | <Button onClick={handleLogin}> | ||
| 602 | <LogIn className="mr-2 h-4 w-4" /> | ||
| 603 | Login | ||
| 604 | </Button> | ||
| 605 | )} | ||
| 606 | </div> | ||
| 607 | </div> | ||
| 608 | |||
| 609 | {currentView === "drive" ? ( | ||
| 610 | <> | ||
| 611 | {/* Loading State */} | ||
| 612 | {loading && ( | ||
| 613 | <div className="flex items-center justify-center py-8"> | ||
| 614 | <div className="text-center"> | ||
| 615 | <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto mb-2"></div> | ||
| 616 | <p className="text-sm text-muted-foreground">Loading drive contents...</p> | ||
| 617 | </div> | ||
| 618 | </div> | ||
| 619 | )} | ||
| 620 | |||
| 621 | {/* Error State */} | ||
| 622 | {error && ( | ||
| 623 | <div className="bg-red-50 border border-red-200 rounded-lg p-4"> | ||
| 624 | <div className="text-sm text-red-800"> | ||
| 625 | <strong>Error loading drive data:</strong> {error} | ||
| 626 | </div> | ||
| 627 | </div> | ||
| 628 | )} | ||
| 629 | |||
| 630 | {/* Storage Info */} | ||
| 631 | {!loading && !error && ( | ||
| 632 | <div className="bg-card rounded-lg border p-4"> | ||
| 633 | <div className="flex items-center justify-between mb-2"> | ||
| 634 | <span className="text-sm font-medium">Storage Usage</span> | ||
| 635 | <span className="text-sm text-muted-foreground"> | ||
| 636 | {formatFileSize(usedStorage)} of {formatFileSize(maxStorage)} used | ||
| 637 | </span> | ||
| 638 | </div> | ||
| 639 | <Progress value={storagePercentage} className="h-2" /> | ||
| 640 | <div className="flex justify-between text-xs text-muted-foreground mt-1"> | ||
| 641 | <span>{storagePercentage.toFixed(1)}% used</span> | ||
| 642 | <span>{formatFileSize(maxStorage - usedStorage)} available</span> | ||
| 643 | </div> | ||
| 644 | </div> | ||
| 645 | )} | ||
| 646 | |||
| 647 | {/* Bulk Actions */} | ||
| 648 | {!loading && !error && selectedFiles.size > 0 && ( | ||
| 649 | <div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-center justify-between"> | ||
| 650 | <div className="flex items-center gap-4"> | ||
| 651 | <span className="text-sm font-medium text-blue-900"> | ||
| 652 | {selectedFiles.size} item{selectedFiles.size !== 1 ? "s" : ""} selected | ||
| 653 | </span> | ||
| 654 | <Button variant="outline" size="sm" onClick={deselectAll}> | ||
| 655 | Deselect All | ||
| 656 | </Button> | ||
| 657 | </div> | ||
| 658 | <div className="flex items-center gap-2"> | ||
| 659 | <Button | ||
| 660 | variant="outline" | ||
| 661 | size="sm" | ||
| 662 | onClick={() => deleteItems(Array.from(selectedFiles))} | ||
| 663 | className="text-red-600 hover:text-red-700" | ||
| 664 | > | ||
| 665 | <Trash2 className="mr-2 h-4 w-4" /> | ||
| 666 | Delete Selected | ||
| 667 | </Button> | ||
| 668 | </div> | ||
| 669 | </div> | ||
| 670 | )} | ||
| 671 | |||
| 672 | {/* File Table */} | ||
| 673 | {!loading && !error && ( | ||
| 674 | <div className="border rounded-lg"> | ||
| 675 | <Table> | ||
| 676 | <TableHeader> | ||
| 677 | <TableRow> | ||
| 678 | <TableHead className="w-[40px]"></TableHead> | ||
| 679 | <TableHead>Name</TableHead> | ||
| 680 | <TableHead>Size</TableHead> | ||
| 681 | <TableHead>Modified</TableHead> | ||
| 682 | <TableHead className="w-[50px]">Actions</TableHead> | ||
| 683 | </TableRow> | ||
| 684 | </TableHeader> | ||
| 685 | <TableBody>{files.flatMap((file) => renderFileRow(file))}</TableBody> | ||
| 686 | </Table> | ||
| 687 | </div> | ||
| 688 | )} | ||
| 689 | </> | ||
| 690 | ) : ( | ||
| 691 | <HistoryView /> | ||
| 692 | )} | ||
| 693 | |||
| 694 | {/* Rename Dialog */} | ||
| 695 | <Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}> | ||
| 696 | <DialogContent> | ||
| 697 | <DialogHeader> | ||
| 698 | <DialogTitle>Rename {currentItem?.type === "dir" ? "Folder" : "File"}</DialogTitle> | ||
| 699 | </DialogHeader> | ||
| 700 | <div className="space-y-4"> | ||
| 701 | <div> | ||
| 702 | <Label htmlFor="newName">New Name</Label> | ||
| 703 | <Input | ||
| 704 | id="newName" | ||
| 705 | value={newName} | ||
| 706 | onChange={(e) => setNewName(e.target.value)} | ||
| 707 | onKeyDown={(e) => { | ||
| 708 | if (e.key === "Enter") { | ||
| 709 | handleRename() | ||
| 710 | } | ||
| 711 | }} | ||
| 712 | placeholder="Enter new name" | ||
| 713 | /> | ||
| 714 | </div> | ||
| 715 | <div className="flex justify-end gap-2"> | ||
| 716 | <Button variant="outline" onClick={() => setRenameDialogOpen(false)}> | ||
| 717 | Cancel | ||
| 718 | </Button> | ||
| 719 | <Button onClick={handleRename} disabled={!newName.trim()}> | ||
| 720 | Rename | ||
| 721 | </Button> | ||
| 722 | </div> | ||
| 723 | </div> | ||
| 724 | </DialogContent> | ||
| 725 | </Dialog> | ||
| 726 | |||
| 727 | {/* Info Dialog */} | ||
| 728 | <Dialog open={infoDialogOpen} onOpenChange={setInfoDialogOpen}> | ||
| 729 | <DialogContent className="max-w-md"> | ||
| 730 | <DialogHeader> | ||
| 731 | <DialogTitle className="flex items-center gap-2"> | ||
| 732 | {currentItem?.type === "dir" ? ( | ||
| 733 | <Folder className="h-5 w-5 text-blue-500" /> | ||
| 734 | ) : ( | ||
| 735 | <File className="h-5 w-5 text-gray-500" /> | ||
| 736 | )} | ||
| 737 | {currentItem?.type === "dir" ? "Folder" : "File"} Information | ||
| 738 | </DialogTitle> | ||
| 739 | </DialogHeader> | ||
| 740 | {currentItem && ( | ||
| 741 | <div className="space-y-4"> | ||
| 742 | <div className="space-y-3"> | ||
| 743 | <div> | ||
| 744 | <Label className="text-sm font-medium text-muted-foreground">Name</Label> | ||
| 745 | <p className="text-sm break-words">{currentItem.name}</p> | ||
| 746 | </div> | ||
| 747 | <div> | ||
| 748 | <Label className="text-sm font-medium text-muted-foreground">Size</Label> | ||
| 749 | <p className="text-sm">{formatFileSize(currentItem.size || 0)}</p> | ||
| 750 | </div> | ||
| 751 | <div> | ||
| 752 | <Label className="text-sm font-medium text-muted-foreground">Modified</Label> | ||
| 753 | <p className="text-sm">{formatDateTime(currentItem.lastmod)}</p> | ||
| 754 | </div> | ||
| 755 | <div> | ||
| 756 | <Label className="text-sm font-medium text-muted-foreground">Modified By</Label> | ||
| 757 | <p className="text-sm">{currentItem.author}</p> | ||
| 758 | </div> | ||
| 759 | <div> | ||
| 760 | <Label className="text-sm font-medium text-muted-foreground">Type</Label> | ||
| 761 | <p className="text-sm capitalize">{currentItem.type}</p> | ||
| 762 | </div> | ||
| 763 | <div> | ||
| 764 | <Label className="text-sm font-medium text-muted-foreground">Path</Label> | ||
| 765 | <p className="text-sm font-mono text-xs">{currentItem.path}</p> | ||
| 766 | </div> | ||
| 767 | </div> | ||
| 768 | <div className="flex justify-end"> | ||
| 769 | <Button variant="outline" onClick={() => setInfoDialogOpen(false)}> | ||
| 770 | Close | ||
| 771 | </Button> | ||
| 772 | </div> | ||
| 773 | </div> | ||
| 774 | )} | ||
| 775 | </DialogContent> | ||
| 776 | </Dialog> | ||
| 777 | <input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileUpload} /> | ||
| 778 | {/* Hidden file inputs for folder uploads */} | ||
| 779 | {(() => { | ||
| 780 | const getAllFolders = (items: DriveTreeNode[]): DriveTreeNode[] => { | ||
| 781 | const folders: DriveTreeNode[] = [] | ||
| 782 | items.forEach((item) => { | ||
| 783 | if (item.type === "dir") { | ||
| 784 | folders.push(item) | ||
| 785 | if (item.children) { | ||
| 786 | folders.push(...getAllFolders(item.children)) | ||
| 787 | } | ||
| 788 | } | ||
| 789 | }) | ||
| 790 | return folders | ||
| 791 | } | ||
| 792 | return getAllFolders(files).map((folder) => ( | ||
| 793 | <input | ||
| 794 | key={folder.path} | ||
| 795 | id={`folder-upload-${folder.path}`} | ||
| 796 | type="file" | ||
| 797 | multiple | ||
| 798 | className="hidden" | ||
| 799 | onChange={(e) => handleFolderUpload(e, folder.path)} | ||
| 800 | /> | ||
| 801 | )) | ||
| 802 | })()} | ||
| 803 | </div> | ||
| 804 | ) | ||
| 805 | } | ||
diff --git a/frontend/history-view.tsx b/frontend/history-view.tsx deleted file mode 100644 index 12af1ea..0000000 --- a/frontend/history-view.tsx +++ /dev/null | |||
| @@ -1,252 +0,0 @@ | |||
| 1 | "use client" | ||
| 2 | |||
| 3 | import { useState, useEffect } from "react" | ||
| 4 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" | ||
| 5 | import { Badge } from "@/components/ui/badge" | ||
| 6 | import { Button } from "@/components/ui/button" | ||
| 7 | import { FileText, Folder, Trash2, Edit, Plus, ChevronLeft, ChevronRight } from "lucide-react" | ||
| 8 | import { DriveLogEntry } from "@/lib/drive_types" | ||
| 9 | import { formatFileSize } from "@/lib/utils" | ||
| 10 | |||
| 11 | |||
| 12 | function formatTimestamp(timestamp: number): string { | ||
| 13 | const date = new Date(timestamp * 1000) // Convert Unix timestamp to milliseconds | ||
| 14 | return date.toLocaleString("en-US", { | ||
| 15 | year: "numeric", | ||
| 16 | month: "short", | ||
| 17 | day: "numeric", | ||
| 18 | hour: "2-digit", | ||
| 19 | minute: "2-digit", | ||
| 20 | hour12: true, | ||
| 21 | }) | ||
| 22 | } | ||
| 23 | |||
| 24 | function getActionIcon(action: string) { | ||
| 25 | switch (action) { | ||
| 26 | case "create_file": | ||
| 27 | return <FileText className="h-4 w-4 text-green-600" /> | ||
| 28 | case "create_dir": | ||
| 29 | return <Folder className="h-4 w-4 text-blue-600" /> | ||
| 30 | case "remove": | ||
| 31 | return <Trash2 className="h-4 w-4 text-red-600" /> | ||
| 32 | default: | ||
| 33 | return <Plus className="h-4 w-4 text-gray-600" /> | ||
| 34 | } | ||
| 35 | } | ||
| 36 | |||
| 37 | function getActionBadge(action: string) { | ||
| 38 | switch (action) { | ||
| 39 | case "create_file": | ||
| 40 | return ( | ||
| 41 | <Badge variant="outline" className="text-green-700 border-green-300 bg-green-50"> | ||
| 42 | File Created | ||
| 43 | </Badge> | ||
| 44 | ) | ||
| 45 | case "create_dir": | ||
| 46 | return ( | ||
| 47 | <Badge variant="outline" className="text-blue-700 border-blue-300 bg-blue-50"> | ||
| 48 | Directory Created | ||
| 49 | </Badge> | ||
| 50 | ) | ||
| 51 | case "remove": | ||
| 52 | return ( | ||
| 53 | <Badge variant="outline" className="text-red-700 border-red-300 bg-red-50"> | ||
| 54 | File/Directory Removed | ||
| 55 | </Badge> | ||
| 56 | ) | ||
| 57 | default: | ||
| 58 | return ( | ||
| 59 | <Badge variant="outline" className="text-gray-700 border-gray-300 bg-gray-50"> | ||
| 60 | {action} | ||
| 61 | </Badge> | ||
| 62 | ) | ||
| 63 | } | ||
| 64 | } | ||
| 65 | |||
| 66 | const ENTRIES_PER_PAGE = 100 | ||
| 67 | |||
| 68 | export default function HistoryView() { | ||
| 69 | const [logEntries, setLogEntries] = useState<DriveLogEntry[]>([]) | ||
| 70 | const [loading, setLoading] = useState(true) | ||
| 71 | const [error, setError] = useState<string | null>(null) | ||
| 72 | const [currentPage, setCurrentPage] = useState(1) | ||
| 73 | |||
| 74 | useEffect(() => { | ||
| 75 | async function fetchLogEntries() { | ||
| 76 | try { | ||
| 77 | setLoading(true) | ||
| 78 | const response = await fetch('/api/log') | ||
| 79 | if (!response.ok) { | ||
| 80 | throw new Error('Failed to fetch log entries') | ||
| 81 | } | ||
| 82 | const data: DriveLogEntry[] = await response.json() | ||
| 83 | // Reverse to show latest entries first | ||
| 84 | setLogEntries(data.reverse()) | ||
| 85 | } catch (err) { | ||
| 86 | setError(err instanceof Error ? err.message : 'Unknown error') | ||
| 87 | } finally { | ||
| 88 | setLoading(false) | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | fetchLogEntries() | ||
| 93 | }, []) | ||
| 94 | |||
| 95 | // Calculate pagination values | ||
| 96 | const totalPages = Math.ceil(logEntries.length / ENTRIES_PER_PAGE) | ||
| 97 | const startIndex = (currentPage - 1) * ENTRIES_PER_PAGE | ||
| 98 | const endIndex = startIndex + ENTRIES_PER_PAGE | ||
| 99 | const currentEntries = logEntries.slice(startIndex, endIndex) | ||
| 100 | |||
| 101 | const handlePageChange = (page: number) => { | ||
| 102 | setCurrentPage(page) | ||
| 103 | } | ||
| 104 | |||
| 105 | const handlePreviousPage = () => { | ||
| 106 | setCurrentPage(prev => Math.max(1, prev - 1)) | ||
| 107 | } | ||
| 108 | |||
| 109 | const handleNextPage = () => { | ||
| 110 | setCurrentPage(prev => Math.min(totalPages, prev + 1)) | ||
| 111 | } | ||
| 112 | |||
| 113 | if (loading) { | ||
| 114 | return ( | ||
| 115 | <div className="space-y-6"> | ||
| 116 | <div className="flex items-center justify-between"> | ||
| 117 | <div> | ||
| 118 | <h2 className="text-xl font-semibold">Activity History</h2> | ||
| 119 | <p className="text-sm text-muted-foreground">Recent filesystem modifications and changes</p> | ||
| 120 | </div> | ||
| 121 | </div> | ||
| 122 | <div className="text-center py-8"> | ||
| 123 | <p>Loading history...</p> | ||
| 124 | </div> | ||
| 125 | </div> | ||
| 126 | ) | ||
| 127 | } | ||
| 128 | |||
| 129 | if (error) { | ||
| 130 | return ( | ||
| 131 | <div className="space-y-6"> | ||
| 132 | <div className="flex items-center justify-between"> | ||
| 133 | <div> | ||
| 134 | <h2 className="text-xl font-semibold">Activity History</h2> | ||
| 135 | <p className="text-sm text-muted-foreground">Recent filesystem modifications and changes</p> | ||
| 136 | </div> | ||
| 137 | </div> | ||
| 138 | <div className="text-center py-8 text-red-600"> | ||
| 139 | <p>Error: {error}</p> | ||
| 140 | </div> | ||
| 141 | </div> | ||
| 142 | ) | ||
| 143 | } | ||
| 144 | |||
| 145 | return ( | ||
| 146 | <div className="space-y-6"> | ||
| 147 | {/* History Header */} | ||
| 148 | <div className="flex items-center justify-between"> | ||
| 149 | <div> | ||
| 150 | <h2 className="text-xl font-semibold">Activity History</h2> | ||
| 151 | <p className="text-sm text-muted-foreground">Recent filesystem modifications and changes</p> | ||
| 152 | </div> | ||
| 153 | <Badge variant="secondary">{logEntries.length} total entries</Badge> | ||
| 154 | </div> | ||
| 155 | |||
| 156 | {/* History Table */} | ||
| 157 | <div className="border rounded-lg"> | ||
| 158 | <Table> | ||
| 159 | <TableHeader> | ||
| 160 | <TableRow> | ||
| 161 | <TableHead>Type</TableHead> | ||
| 162 | <TableHead>File/Directory Name</TableHead> | ||
| 163 | <TableHead>Time</TableHead> | ||
| 164 | <TableHead>User</TableHead> | ||
| 165 | <TableHead>Size</TableHead> | ||
| 166 | </TableRow> | ||
| 167 | </TableHeader> | ||
| 168 | <TableBody> | ||
| 169 | {currentEntries.map((entry) => ( | ||
| 170 | <TableRow key={`${entry.log_id}`} className="hover:bg-muted/50"> | ||
| 171 | <TableCell>{getActionBadge(entry.action)}</TableCell> | ||
| 172 | <TableCell className="font-medium"> | ||
| 173 | <span className="break-words">{entry.path}</span> | ||
| 174 | </TableCell> | ||
| 175 | <TableCell> | ||
| 176 | <span className="text-sm">{formatTimestamp(entry.timestamp)}</span> | ||
| 177 | </TableCell> | ||
| 178 | <TableCell> | ||
| 179 | <span className="text-sm font-mono">{entry.email}</span> | ||
| 180 | </TableCell> | ||
| 181 | <TableCell> | ||
| 182 | <span className="text-sm text-muted-foreground"> | ||
| 183 | {entry.size ? formatFileSize(entry.size) : "—"} | ||
| 184 | </span> | ||
| 185 | </TableCell> | ||
| 186 | </TableRow> | ||
| 187 | ))} | ||
| 188 | </TableBody> | ||
| 189 | </Table> | ||
| 190 | </div> | ||
| 191 | |||
| 192 | {/* Pagination */} | ||
| 193 | {totalPages > 1 && ( | ||
| 194 | <div className="flex items-center justify-between"> | ||
| 195 | <div className="text-sm text-muted-foreground"> | ||
| 196 | Showing {startIndex + 1} to {Math.min(endIndex, logEntries.length)} of {logEntries.length} entries | ||
| 197 | </div> | ||
| 198 | <div className="flex items-center gap-2"> | ||
| 199 | <Button | ||
| 200 | variant="outline" | ||
| 201 | size="sm" | ||
| 202 | onClick={handlePreviousPage} | ||
| 203 | disabled={currentPage === 1} | ||
| 204 | > | ||
| 205 | <ChevronLeft className="h-4 w-4" /> | ||
| 206 | Previous | ||
| 207 | </Button> | ||
| 208 | |||
| 209 | <div className="flex items-center gap-1"> | ||
| 210 | {Array.from({ length: totalPages }, (_, i) => i + 1) | ||
| 211 | .filter(page => { | ||
| 212 | // Show first page, last page, current page, and pages around current | ||
| 213 | return page === 1 || | ||
| 214 | page === totalPages || | ||
| 215 | Math.abs(page - currentPage) <= 2 | ||
| 216 | }) | ||
| 217 | .map((page, index, filteredPages) => { | ||
| 218 | // Add ellipsis where there are gaps | ||
| 219 | const showEllipsisBefore = index > 0 && page - filteredPages[index - 1] > 1 | ||
| 220 | return ( | ||
| 221 | <div key={page} className="flex items-center gap-1"> | ||
| 222 | {showEllipsisBefore && ( | ||
| 223 | <span className="text-muted-foreground px-2">...</span> | ||
| 224 | )} | ||
| 225 | <Button | ||
| 226 | variant={currentPage === page ? "default" : "outline"} | ||
| 227 | size="sm" | ||
| 228 | onClick={() => handlePageChange(page)} | ||
| 229 | className="w-8 h-8 p-0" | ||
| 230 | > | ||
| 231 | {page} | ||
| 232 | </Button> | ||
| 233 | </div> | ||
| 234 | ) | ||
| 235 | })} | ||
| 236 | </div> | ||
| 237 | |||
| 238 | <Button | ||
| 239 | variant="outline" | ||
| 240 | size="sm" | ||
| 241 | onClick={handleNextPage} | ||
| 242 | disabled={currentPage === totalPages} | ||
| 243 | > | ||
| 244 | Next | ||
| 245 | <ChevronRight className="h-4 w-4" /> | ||
| 246 | </Button> | ||
| 247 | </div> | ||
| 248 | </div> | ||
| 249 | )} | ||
| 250 | </div> | ||
| 251 | ) | ||
| 252 | } | ||
