summaryrefslogtreecommitdiff
path: root/frontend
diff options
context:
space:
mode:
authordiogo464 <[email protected]>2025-08-12 16:28:33 +0100
committerdiogo464 <[email protected]>2025-08-12 16:28:33 +0100
commit70738d871decbcdec4f5535a7b6f57de26de7d2a (patch)
tree6402994fefa3bed8c352e24d20b53cb59a75afa0 /frontend
parent507d9ee9ae524edd4e39942b735d987aa5d48359 (diff)
Clean up old UI code and rename V2 to Drive
- Remove old UI components: file-drive.tsx, history-view.tsx - Remove unused API endpoints: /api/tree, /api/log, /api/fs/route.ts - Rename /v2 routes to /drive routes for cleaner URLs - Rename V2* components to Drive* components (V2DirectoryView -> DriveDirectoryView, etc.) - Update all breadcrumb and navigation references from /v2 to /drive - Redirect root path to /drive instead of old UI - Keep /api/fs/[...path] and /api/directories for uploads and move functionality - Preserve Drive_* server functions for potential future use 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
Diffstat (limited to 'frontend')
-rw-r--r--frontend/app/api/fs/route.ts19
-rw-r--r--frontend/app/api/log/route.ts13
-rw-r--r--frontend/app/api/tree/route.ts13
-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.tsx7
-rw-r--r--frontend/app/page.tsx4
-rw-r--r--frontend/app/v2/page.tsx7
-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.tsx805
-rw-r--r--frontend/history-view.tsx252
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 @@
1import { NextResponse } from 'next/server'
2import { Drive_ls } from '@/lib/drive_server'
3
4// GET /api/fs - Get root directory listing
5export 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 @@
1import { NextResponse } from 'next/server'
2import { Drive_log } from '@/lib/drive_server'
3
4export 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 @@
1import { NextResponse } from 'next/server'
2import { Drive_tree } from '@/lib/drive_server'
3
4export 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 @@
1import { V2DirectoryView } from "@/components/v2/V2DirectoryView" 1import { DriveDirectoryView } from "@/components/drive/DriveDirectoryView"
2import { Drive_ls } from "@/lib/drive_server" 2import { Drive_ls } from "@/lib/drive_server"
3 3
4export default async function V2DirectoryPage({ 4export 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 @@
1import { DriveDirectoryView } from "@/components/drive/DriveDirectoryView"
2import { Drive_ls } from "@/lib/drive_server"
3
4export 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 @@
1import FileDrive from "../file-drive" 1import { redirect } from 'next/navigation'
2 2
3export default function Page() { 3export 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 @@
1import { V2DirectoryView } from "@/components/v2/V2DirectoryView"
2import { Drive_ls } from "@/lib/drive_server"
3
4export 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"
35import { toast } from "@/hooks/use-toast" 35import { toast } from "@/hooks/use-toast"
36import { DriveLsEntry } from "@/lib/drive_types" 36import { DriveLsEntry } from "@/lib/drive_types"
37import { UPLOAD_MAX_FILE_SIZE, UPLOAD_MAX_FILES } from "@/lib/constants" 37import { UPLOAD_MAX_FILE_SIZE, UPLOAD_MAX_FILES } from "@/lib/constants"
38import { V2MoveDialog } from "./V2MoveDialog" 38import { DriveMoveDialog } from "./DriveMoveDialog"
39 39
40function formatFileSize(bytes: number): string { 40function 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
69interface V2DirectoryClientProps { 69interface DriveDirectoryClientProps {
70 path: string 70 path: string
71 files: DriveLsEntry[] 71 files: DriveLsEntry[]
72 breadcrumbs: Breadcrumb[] 72 breadcrumbs: Breadcrumb[]
73} 73}
74 74
75export function V2DirectoryClient({ path, files, breadcrumbs }: V2DirectoryClientProps) { 75export 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 @@
1import { DriveLsEntry } from "@/lib/drive_types" 1import { DriveLsEntry } from "@/lib/drive_types"
2import { V2DirectoryClient } from "./V2DirectoryClient" 2import { DriveDirectoryClient } from "./DriveDirectoryClient"
3 3
4interface V2DirectoryViewProps { 4interface 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
10function generateBreadcrumbs(currentPath: string) { 10function 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
44export function V2DirectoryView({ path, files }: V2DirectoryViewProps) { 44export 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"
9import { toast } from "@/hooks/use-toast" 9import { toast } from "@/hooks/use-toast"
10import { DriveLsEntry } from "@/lib/drive_types" 10import { DriveLsEntry } from "@/lib/drive_types"
11 11
12interface V2MoveDialogProps { 12interface 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
19export function V2MoveDialog({ open, onOpenChange, selectedCount, onMove }: V2MoveDialogProps) { 19export 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
3import type React from "react"
4
5import { useState, useRef, useEffect } from "react"
6import {
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"
23import { Button } from "@/components/ui/button"
24import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
25import { Progress } from "@/components/ui/progress"
26import {
27 DropdownMenu,
28 DropdownMenuContent,
29 DropdownMenuItem,
30 DropdownMenuTrigger,
31 DropdownMenuSeparator,
32} from "@/components/ui/dropdown-menu"
33import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
34import { Input } from "@/components/ui/input"
35import { Label } from "@/components/ui/label"
36import { Checkbox } from "@/components/ui/checkbox"
37import { toast } from "@/hooks/use-toast"
38import HistoryView from "./history-view"
39import { DriveTreeResponse, DriveTreeNode } from "@/lib/drive_types"
40import { UPLOAD_MAX_FILE_SIZE, UPLOAD_MAX_FILES } from "@/lib/constants"
41
42
43function 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
51function 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
61async 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
70function 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
75function 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
87export 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
3import { useState, useEffect } from "react"
4import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
5import { Badge } from "@/components/ui/badge"
6import { Button } from "@/components/ui/button"
7import { FileText, Folder, Trash2, Edit, Plus, ChevronLeft, ChevronRight } from "lucide-react"
8import { DriveLogEntry } from "@/lib/drive_types"
9import { formatFileSize } from "@/lib/utils"
10
11
12function 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
24function 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
37function 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
66const ENTRIES_PER_PAGE = 100
67
68export 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}