diff options
| author | diogo464 <[email protected]> | 2025-08-11 11:51:39 +0100 |
|---|---|---|
| committer | diogo464 <[email protected]> | 2025-08-11 11:51:39 +0100 |
| commit | 4af66f418b6837b6441b4e8eaf2d8ede585238b9 (patch) | |
| tree | 34a4e913a2848515166b2ac0489794419a33bfcc /frontend/app | |
| parent | 0d3488a3811c8d58bd570af64cc29840df9ba439 (diff) | |
snapshot
Diffstat (limited to 'frontend/app')
| -rw-r--r-- | frontend/app/blob/[blobId]/route.ts | 80 | ||||
| -rw-r--r-- | frontend/app/drive/[...path]/page.tsx | 136 | ||||
| -rw-r--r-- | frontend/app/drive/page.tsx | 94 | ||||
| -rw-r--r-- | frontend/app/favicon.ico | bin | 0 -> 25931 bytes | |||
| -rw-r--r-- | frontend/app/globals.css | 26 | ||||
| -rw-r--r-- | frontend/app/layout.tsx | 38 | ||||
| -rw-r--r-- | frontend/app/page.tsx | 5 |
7 files changed, 379 insertions, 0 deletions
diff --git a/frontend/app/blob/[blobId]/route.ts b/frontend/app/blob/[blobId]/route.ts new file mode 100644 index 0000000..1643b70 --- /dev/null +++ b/frontend/app/blob/[blobId]/route.ts | |||
| @@ -0,0 +1,80 @@ | |||
| 1 | import { Drive_blob_path } from "@/lib/drive" | ||
| 2 | import { NextRequest, NextResponse } from "next/server" | ||
| 3 | import { createReadStream, statSync } from "fs" | ||
| 4 | import path from "path" | ||
| 5 | |||
| 6 | export async function GET( | ||
| 7 | request: NextRequest, | ||
| 8 | { params }: { params: Promise<{ blobId: string }> } | ||
| 9 | ) { | ||
| 10 | try { | ||
| 11 | const { blobId } = await params | ||
| 12 | const { searchParams } = new URL(request.url) | ||
| 13 | const filename = searchParams.get('filename') || 'download' | ||
| 14 | |||
| 15 | // Get the filesystem path for this blob | ||
| 16 | const blobPath = await Drive_blob_path(blobId) | ||
| 17 | |||
| 18 | // Get file stats | ||
| 19 | const stats = statSync(blobPath) | ||
| 20 | const fileSize = stats.size | ||
| 21 | |||
| 22 | // Determine content type based on file extension | ||
| 23 | const ext = path.extname(filename).toLowerCase() | ||
| 24 | const contentType = getContentType(ext) | ||
| 25 | |||
| 26 | // Create readable stream | ||
| 27 | const stream = createReadStream(blobPath) | ||
| 28 | |||
| 29 | // Convert stream to web stream | ||
| 30 | const readableStream = new ReadableStream({ | ||
| 31 | start(controller) { | ||
| 32 | stream.on('data', (chunk: Buffer) => { | ||
| 33 | controller.enqueue(new Uint8Array(chunk)) | ||
| 34 | }) | ||
| 35 | stream.on('end', () => { | ||
| 36 | controller.close() | ||
| 37 | }) | ||
| 38 | stream.on('error', (error) => { | ||
| 39 | controller.error(error) | ||
| 40 | }) | ||
| 41 | } | ||
| 42 | }) | ||
| 43 | |||
| 44 | return new NextResponse(readableStream, { | ||
| 45 | headers: { | ||
| 46 | 'Content-Type': contentType, | ||
| 47 | 'Content-Length': fileSize.toString(), | ||
| 48 | 'Content-Disposition': `attachment; filename="${filename}"`, | ||
| 49 | 'Cache-Control': 'public, max-age=31536000', // Cache for 1 year | ||
| 50 | }, | ||
| 51 | }) | ||
| 52 | |||
| 53 | } catch (error) { | ||
| 54 | console.error('Error serving blob:', error) | ||
| 55 | return new NextResponse('File not found', { status: 404 }) | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | function getContentType(extension: string): string { | ||
| 60 | const mimeTypes: Record<string, string> = { | ||
| 61 | '.pdf': 'application/pdf', | ||
| 62 | '.txt': 'text/plain', | ||
| 63 | '.md': 'text/markdown', | ||
| 64 | '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', | ||
| 65 | '.doc': 'application/msword', | ||
| 66 | '.zip': 'application/zip', | ||
| 67 | '.png': 'image/png', | ||
| 68 | '.jpg': 'image/jpeg', | ||
| 69 | '.jpeg': 'image/jpeg', | ||
| 70 | '.gif': 'image/gif', | ||
| 71 | '.svg': 'image/svg+xml', | ||
| 72 | '.css': 'text/css', | ||
| 73 | '.js': 'application/javascript', | ||
| 74 | '.json': 'application/json', | ||
| 75 | '.html': 'text/html', | ||
| 76 | '.htm': 'text/html', | ||
| 77 | } | ||
| 78 | |||
| 79 | return mimeTypes[extension] || 'application/octet-stream' | ||
| 80 | } \ No newline at end of file | ||
diff --git a/frontend/app/drive/[...path]/page.tsx b/frontend/app/drive/[...path]/page.tsx new file mode 100644 index 0000000..75a1bb1 --- /dev/null +++ b/frontend/app/drive/[...path]/page.tsx | |||
| @@ -0,0 +1,136 @@ | |||
| 1 | import { Drive_ls, Drive_basename, Drive_parent } from "@/lib/drive" | ||
| 2 | import { formatSize } from "@/lib/utils" | ||
| 3 | import Link from "next/link" | ||
| 4 | import { cookies } from 'next/headers'; | ||
| 5 | import { Auth_get_user } from "@/lib/auth"; | ||
| 6 | |||
| 7 | interface DrivePageProps { | ||
| 8 | params: Promise<{ | ||
| 9 | path: string[] | ||
| 10 | }> | ||
| 11 | } | ||
| 12 | |||
| 13 | export default async function DrivePage({ params }: DrivePageProps) { | ||
| 14 | // Await params as required by Next.js 15 | ||
| 15 | const { path } = await params | ||
| 16 | |||
| 17 | const user = await Auth_get_user(); | ||
| 18 | console.log(user); | ||
| 19 | |||
| 20 | // Construct the full path from params | ||
| 21 | const fullPath = path ? `/${path.join('/')}` : "" | ||
| 22 | |||
| 23 | const entries = await Drive_ls(fullPath, false) | ||
| 24 | |||
| 25 | // Check if we have a parent directory | ||
| 26 | const parentDir = Drive_parent(fullPath) | ||
| 27 | const parentPath = path && path.length > 1 | ||
| 28 | ? `/drive/${path.slice(0, -1).join('/')}` | ||
| 29 | : path && path.length === 1 | ||
| 30 | ? '/drive' | ||
| 31 | : null | ||
| 32 | |||
| 33 | // Create entries with optional parent directory at top | ||
| 34 | const allEntries = [] | ||
| 35 | if (parentDir !== null && parentPath !== null) { | ||
| 36 | allEntries.push({ | ||
| 37 | path: '(parent)', | ||
| 38 | type: 'dir' as const, | ||
| 39 | lastmod: 0, | ||
| 40 | blob: null, | ||
| 41 | size: null, | ||
| 42 | author: '', | ||
| 43 | isParent: true, | ||
| 44 | parentPath | ||
| 45 | }) | ||
| 46 | } | ||
| 47 | |||
| 48 | // Sort entries: directories first, then files, both alphabetically | ||
| 49 | const sortedEntries = entries.sort((a, b) => { | ||
| 50 | // First sort by type (directories before files) | ||
| 51 | if (a.type !== b.type) { | ||
| 52 | return a.type === 'dir' ? -1 : 1 | ||
| 53 | } | ||
| 54 | // Then sort alphabetically by path | ||
| 55 | return a.path.localeCompare(b.path) | ||
| 56 | }) | ||
| 57 | |||
| 58 | allEntries.push(...sortedEntries) | ||
| 59 | |||
| 60 | return ( | ||
| 61 | <div className="min-h-screen bg-background"> | ||
| 62 | <div className="container mx-auto p-4"> | ||
| 63 | <Link href="/drive" className="inline-block mb-6"> | ||
| 64 | <h1 className="text-2xl font-bold text-foreground hover:text-blue-600 dark:hover:text-blue-400 transition-colors cursor-pointer">FCTDrive</h1> | ||
| 65 | </Link> | ||
| 66 | |||
| 67 | <div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden"> | ||
| 68 | <div className="overflow-x-auto"> | ||
| 69 | <table className="w-full"> | ||
| 70 | <thead className="bg-gray-50 dark:bg-gray-700"> | ||
| 71 | <tr> | ||
| 72 | <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"> | ||
| 73 | Name | ||
| 74 | </th> | ||
| 75 | <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"> | ||
| 76 | Size | ||
| 77 | </th> | ||
| 78 | <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"> | ||
| 79 | Author | ||
| 80 | </th> | ||
| 81 | <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"> | ||
| 82 | Modified | ||
| 83 | </th> | ||
| 84 | </tr> | ||
| 85 | </thead> | ||
| 86 | <tbody className="divide-y divide-gray-200 dark:divide-gray-600"> | ||
| 87 | {allEntries.map((entry, index) => ( | ||
| 88 | <tr | ||
| 89 | key={entry.path} | ||
| 90 | className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" | ||
| 91 | > | ||
| 92 | <td className="px-4 py-4 whitespace-nowrap"> | ||
| 93 | <div className="flex items-center"> | ||
| 94 | <div className="flex-shrink-0 h-5 w-5 mr-3"> | ||
| 95 | {entry.type === 'dir' ? ( | ||
| 96 | <div className="h-5 w-5 text-blue-500">📁</div> | ||
| 97 | ) : ( | ||
| 98 | <div className="h-5 w-5 text-gray-400">📄</div> | ||
| 99 | )} | ||
| 100 | </div> | ||
| 101 | {entry.type === 'dir' ? ( | ||
| 102 | <Link | ||
| 103 | href={(entry as any).isParent ? (entry as any).parentPath : `/drive${entry.path}`} | ||
| 104 | className="text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline" | ||
| 105 | > | ||
| 106 | {(entry as any).isParent ? '(parent)' : Drive_basename(entry.path)} | ||
| 107 | </Link> | ||
| 108 | ) : ( | ||
| 109 | <Link | ||
| 110 | href={`/blob/${entry.blob}?filename=${encodeURIComponent(Drive_basename(entry.path))}`} | ||
| 111 | className="text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline" | ||
| 112 | > | ||
| 113 | {Drive_basename(entry.path)} | ||
| 114 | </Link> | ||
| 115 | )} | ||
| 116 | </div> | ||
| 117 | </td> | ||
| 118 | <td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300"> | ||
| 119 | {formatSize(entry.size)} | ||
| 120 | </td> | ||
| 121 | <td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300"> | ||
| 122 | {entry.author} | ||
| 123 | </td> | ||
| 124 | <td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300"> | ||
| 125 | {new Date(entry.lastmod * 1000).toLocaleString()} | ||
| 126 | </td> | ||
| 127 | </tr> | ||
| 128 | ))} | ||
| 129 | </tbody> | ||
| 130 | </table> | ||
| 131 | </div> | ||
| 132 | </div> | ||
| 133 | </div> | ||
| 134 | </div> | ||
| 135 | ) | ||
| 136 | } | ||
diff --git a/frontend/app/drive/page.tsx b/frontend/app/drive/page.tsx new file mode 100644 index 0000000..218774a --- /dev/null +++ b/frontend/app/drive/page.tsx | |||
| @@ -0,0 +1,94 @@ | |||
| 1 | import { Drive_ls, Drive_basename } from "@/lib/drive" | ||
| 2 | import { formatSize } from "@/lib/utils" | ||
| 3 | import Link from "next/link" | ||
| 4 | |||
| 5 | export default async function DrivePage() { | ||
| 6 | const entries = await Drive_ls("", false) | ||
| 7 | |||
| 8 | // Sort entries: directories first, then files, both alphabetically | ||
| 9 | const sortedEntries = entries.sort((a, b) => { | ||
| 10 | // First sort by type (directories before files) | ||
| 11 | if (a.type !== b.type) { | ||
| 12 | return a.type === 'dir' ? -1 : 1 | ||
| 13 | } | ||
| 14 | // Then sort alphabetically by path | ||
| 15 | return a.path.localeCompare(b.path) | ||
| 16 | }) | ||
| 17 | |||
| 18 | return ( | ||
| 19 | <div className="min-h-screen bg-background"> | ||
| 20 | <div className="container mx-auto p-4"> | ||
| 21 | <Link href="/drive" className="inline-block mb-6"> | ||
| 22 | <h1 className="text-2xl font-bold text-foreground hover:text-blue-600 dark:hover:text-blue-400 transition-colors cursor-pointer">FCTDrive</h1> | ||
| 23 | </Link> | ||
| 24 | |||
| 25 | <div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden"> | ||
| 26 | <div className="overflow-x-auto"> | ||
| 27 | <table className="w-full"> | ||
| 28 | <thead className="bg-gray-50 dark:bg-gray-700"> | ||
| 29 | <tr> | ||
| 30 | <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"> | ||
| 31 | Name | ||
| 32 | </th> | ||
| 33 | <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"> | ||
| 34 | Size | ||
| 35 | </th> | ||
| 36 | <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"> | ||
| 37 | Author | ||
| 38 | </th> | ||
| 39 | <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"> | ||
| 40 | Modified | ||
| 41 | </th> | ||
| 42 | </tr> | ||
| 43 | </thead> | ||
| 44 | <tbody className="divide-y divide-gray-200 dark:divide-gray-600"> | ||
| 45 | {sortedEntries.map((entry, index) => ( | ||
| 46 | <tr | ||
| 47 | key={entry.path} | ||
| 48 | className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" | ||
| 49 | > | ||
| 50 | <td className="px-4 py-4 whitespace-nowrap"> | ||
| 51 | <div className="flex items-center"> | ||
| 52 | <div className="flex-shrink-0 h-5 w-5 mr-3"> | ||
| 53 | {entry.type === 'dir' ? ( | ||
| 54 | <div className="h-5 w-5 text-blue-500">📁</div> | ||
| 55 | ) : ( | ||
| 56 | <div className="h-5 w-5 text-gray-400">📄</div> | ||
| 57 | )} | ||
| 58 | </div> | ||
| 59 | {entry.type === 'dir' ? ( | ||
| 60 | <Link | ||
| 61 | href={`/drive${entry.path}`} | ||
| 62 | className="text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline" | ||
| 63 | > | ||
| 64 | {Drive_basename(entry.path)} | ||
| 65 | </Link> | ||
| 66 | ) : ( | ||
| 67 | <Link | ||
| 68 | href={`/blob/${entry.blob}?filename=${encodeURIComponent(Drive_basename(entry.path))}`} | ||
| 69 | className="text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline" | ||
| 70 | > | ||
| 71 | {Drive_basename(entry.path)} | ||
| 72 | </Link> | ||
| 73 | )} | ||
| 74 | </div> | ||
| 75 | </td> | ||
| 76 | <td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300"> | ||
| 77 | {formatSize(entry.size)} | ||
| 78 | </td> | ||
| 79 | <td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300"> | ||
| 80 | {entry.author} | ||
| 81 | </td> | ||
| 82 | <td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300"> | ||
| 83 | {new Date(entry.lastmod * 1000).toLocaleString()} | ||
| 84 | </td> | ||
| 85 | </tr> | ||
| 86 | ))} | ||
| 87 | </tbody> | ||
| 88 | </table> | ||
| 89 | </div> | ||
| 90 | </div> | ||
| 91 | </div> | ||
| 92 | </div> | ||
| 93 | ) | ||
| 94 | } \ No newline at end of file | ||
diff --git a/frontend/app/favicon.ico b/frontend/app/favicon.ico new file mode 100644 index 0000000..718d6fe --- /dev/null +++ b/frontend/app/favicon.ico | |||
| Binary files differ | |||
diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..a2dc41e --- /dev/null +++ b/frontend/app/globals.css | |||
| @@ -0,0 +1,26 @@ | |||
| 1 | @import "tailwindcss"; | ||
| 2 | |||
| 3 | :root { | ||
| 4 | --background: #ffffff; | ||
| 5 | --foreground: #171717; | ||
| 6 | } | ||
| 7 | |||
| 8 | @theme inline { | ||
| 9 | --color-background: var(--background); | ||
| 10 | --color-foreground: var(--foreground); | ||
| 11 | --font-sans: var(--font-geist-sans); | ||
| 12 | --font-mono: var(--font-geist-mono); | ||
| 13 | } | ||
| 14 | |||
| 15 | @media (prefers-color-scheme: dark) { | ||
| 16 | :root { | ||
| 17 | --background: #0a0a0a; | ||
| 18 | --foreground: #ededed; | ||
| 19 | } | ||
| 20 | } | ||
| 21 | |||
| 22 | body { | ||
| 23 | background: var(--background); | ||
| 24 | color: var(--foreground); | ||
| 25 | font-family: Arial, Helvetica, sans-serif; | ||
| 26 | } | ||
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..2e001e4 --- /dev/null +++ b/frontend/app/layout.tsx | |||
| @@ -0,0 +1,38 @@ | |||
| 1 | import type { Metadata } from "next"; | ||
| 2 | import { Geist, Geist_Mono } from "next/font/google"; | ||
| 3 | import "./globals.css"; | ||
| 4 | import AuthButton from "@/components/AuthButton"; | ||
| 5 | |||
| 6 | const geistSans = Geist({ | ||
| 7 | variable: "--font-geist-sans", | ||
| 8 | subsets: ["latin"], | ||
| 9 | }); | ||
| 10 | |||
| 11 | const geistMono = Geist_Mono({ | ||
| 12 | variable: "--font-geist-mono", | ||
| 13 | subsets: ["latin"], | ||
| 14 | }); | ||
| 15 | |||
| 16 | export const metadata: Metadata = { | ||
| 17 | title: "FCTDrive", | ||
| 18 | description: "Simple file browsing interface for FCTDrive", | ||
| 19 | }; | ||
| 20 | |||
| 21 | export default function RootLayout({ | ||
| 22 | children, | ||
| 23 | }: Readonly<{ | ||
| 24 | children: React.ReactNode; | ||
| 25 | }>) { | ||
| 26 | return ( | ||
| 27 | <html lang="en"> | ||
| 28 | <body | ||
| 29 | className={`${geistSans.variable} ${geistMono.variable} antialiased`} | ||
| 30 | > | ||
| 31 | <div className="absolute top-4 right-4 z-10"> | ||
| 32 | <AuthButton /> | ||
| 33 | </div> | ||
| 34 | {children} | ||
| 35 | </body> | ||
| 36 | </html> | ||
| 37 | ); | ||
| 38 | } | ||
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..3333985 --- /dev/null +++ b/frontend/app/page.tsx | |||
| @@ -0,0 +1,5 @@ | |||
| 1 | import { redirect } from 'next/navigation' | ||
| 2 | |||
| 3 | export default function Home() { | ||
| 4 | redirect('/drive') | ||
| 5 | } | ||
