345 lines
9.5 KiB
TypeScript
345 lines
9.5 KiB
TypeScript
|
|
'use client'
|
||
|
|
|
||
|
|
import { useState } from 'react'
|
||
|
|
import { trpc } from '@/lib/trpc/client'
|
||
|
|
import { Button } from '@/components/ui/button'
|
||
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||
|
|
import { Badge } from '@/components/ui/badge'
|
||
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
||
|
|
import {
|
||
|
|
FileText,
|
||
|
|
Video,
|
||
|
|
File,
|
||
|
|
Download,
|
||
|
|
ExternalLink,
|
||
|
|
Play,
|
||
|
|
FileImage,
|
||
|
|
Loader2,
|
||
|
|
AlertCircle,
|
||
|
|
X,
|
||
|
|
} from 'lucide-react'
|
||
|
|
import { cn } from '@/lib/utils'
|
||
|
|
|
||
|
|
interface ProjectFile {
|
||
|
|
id: string
|
||
|
|
fileType: 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER' | 'BUSINESS_PLAN' | 'VIDEO_PITCH' | 'SUPPORTING_DOC'
|
||
|
|
fileName: string
|
||
|
|
mimeType: string
|
||
|
|
size: number
|
||
|
|
bucket: string
|
||
|
|
objectKey: string
|
||
|
|
}
|
||
|
|
|
||
|
|
interface FileViewerProps {
|
||
|
|
files: ProjectFile[]
|
||
|
|
className?: string
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatFileSize(bytes: number): string {
|
||
|
|
if (bytes === 0) return '0 Bytes'
|
||
|
|
const k = 1024
|
||
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||
|
|
}
|
||
|
|
|
||
|
|
function getFileIcon(fileType: string, mimeType: string) {
|
||
|
|
if (mimeType.startsWith('video/')) return Video
|
||
|
|
if (mimeType.startsWith('image/')) return FileImage
|
||
|
|
if (mimeType === 'application/pdf') return FileText
|
||
|
|
if (fileType === 'EXEC_SUMMARY' || fileType === 'PRESENTATION') return FileText
|
||
|
|
if (fileType === 'VIDEO') return Video
|
||
|
|
return File
|
||
|
|
}
|
||
|
|
|
||
|
|
function getFileTypeLabel(fileType: string) {
|
||
|
|
switch (fileType) {
|
||
|
|
case 'EXEC_SUMMARY':
|
||
|
|
return 'Executive Summary'
|
||
|
|
case 'PRESENTATION':
|
||
|
|
return 'Presentation'
|
||
|
|
case 'VIDEO':
|
||
|
|
return 'Video'
|
||
|
|
case 'BUSINESS_PLAN':
|
||
|
|
return 'Business Plan'
|
||
|
|
case 'VIDEO_PITCH':
|
||
|
|
return 'Video Pitch'
|
||
|
|
case 'SUPPORTING_DOC':
|
||
|
|
return 'Supporting Document'
|
||
|
|
default:
|
||
|
|
return 'Attachment'
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export function FileViewer({ files, className }: FileViewerProps) {
|
||
|
|
if (files.length === 0) {
|
||
|
|
return (
|
||
|
|
<Card className={className}>
|
||
|
|
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||
|
|
<File className="h-12 w-12 text-muted-foreground/50" />
|
||
|
|
<p className="mt-2 font-medium">No files attached</p>
|
||
|
|
<p className="text-sm text-muted-foreground">
|
||
|
|
This project has no files uploaded yet
|
||
|
|
</p>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Sort files by type order
|
||
|
|
const sortOrder = ['EXEC_SUMMARY', 'BUSINESS_PLAN', 'PRESENTATION', 'VIDEO', 'VIDEO_PITCH', 'SUPPORTING_DOC', 'OTHER']
|
||
|
|
const sortedFiles = [...files].sort(
|
||
|
|
(a, b) => sortOrder.indexOf(a.fileType) - sortOrder.indexOf(b.fileType)
|
||
|
|
)
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Card className={className}>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-lg">Project Files</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-3">
|
||
|
|
{sortedFiles.map((file) => (
|
||
|
|
<FileItem key={file.id} file={file} />
|
||
|
|
))}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function FileItem({ file }: { file: ProjectFile }) {
|
||
|
|
const [showPreview, setShowPreview] = useState(false)
|
||
|
|
const Icon = getFileIcon(file.fileType, file.mimeType)
|
||
|
|
|
||
|
|
const { data: urlData, isLoading: isLoadingUrl } = trpc.file.getDownloadUrl.useQuery(
|
||
|
|
{ bucket: file.bucket, objectKey: file.objectKey },
|
||
|
|
{ enabled: showPreview }
|
||
|
|
)
|
||
|
|
|
||
|
|
const canPreview = file.mimeType.startsWith('video/') || file.mimeType === 'application/pdf'
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-2">
|
||
|
|
<div className="flex items-center gap-3 rounded-lg border p-3">
|
||
|
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||
|
|
<Icon className="h-5 w-5 text-muted-foreground" />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex-1 min-w-0">
|
||
|
|
<p className="font-medium truncate">{file.fileName}</p>
|
||
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
|
|
<Badge variant="secondary" className="text-xs">
|
||
|
|
{getFileTypeLabel(file.fileType)}
|
||
|
|
</Badge>
|
||
|
|
<span>{formatFileSize(file.size)}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
{canPreview && (
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => setShowPreview(!showPreview)}
|
||
|
|
>
|
||
|
|
{showPreview ? (
|
||
|
|
<>
|
||
|
|
<X className="mr-2 h-4 w-4" />
|
||
|
|
Close
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
<Play className="mr-2 h-4 w-4" />
|
||
|
|
Preview
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
<FileDownloadButton file={file} />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Preview area */}
|
||
|
|
{showPreview && (
|
||
|
|
<div className="rounded-lg border bg-muted/50 overflow-hidden">
|
||
|
|
{isLoadingUrl ? (
|
||
|
|
<div className="flex items-center justify-center py-8">
|
||
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||
|
|
</div>
|
||
|
|
) : urlData?.url ? (
|
||
|
|
<FilePreview file={file} url={urlData.url} />
|
||
|
|
) : (
|
||
|
|
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||
|
|
<AlertCircle className="mr-2 h-4 w-4" />
|
||
|
|
Failed to load preview
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function FileDownloadButton({ file }: { file: ProjectFile }) {
|
||
|
|
const [downloading, setDownloading] = useState(false)
|
||
|
|
|
||
|
|
const { refetch } = trpc.file.getDownloadUrl.useQuery(
|
||
|
|
{ bucket: file.bucket, objectKey: file.objectKey },
|
||
|
|
{ enabled: false }
|
||
|
|
)
|
||
|
|
|
||
|
|
const handleDownload = async () => {
|
||
|
|
setDownloading(true)
|
||
|
|
try {
|
||
|
|
const result = await refetch()
|
||
|
|
if (result.data?.url) {
|
||
|
|
// Open in new tab for download
|
||
|
|
window.open(result.data.url, '_blank')
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Failed to get download URL:', error)
|
||
|
|
} finally {
|
||
|
|
setDownloading(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
onClick={handleDownload}
|
||
|
|
disabled={downloading}
|
||
|
|
aria-label="Download file"
|
||
|
|
>
|
||
|
|
{downloading ? (
|
||
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<Download className="h-4 w-4" />
|
||
|
|
)}
|
||
|
|
</Button>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function FilePreview({ file, url }: { file: ProjectFile; url: string }) {
|
||
|
|
if (file.mimeType.startsWith('video/')) {
|
||
|
|
return (
|
||
|
|
<video
|
||
|
|
src={url}
|
||
|
|
controls
|
||
|
|
className="w-full max-h-[500px]"
|
||
|
|
preload="metadata"
|
||
|
|
>
|
||
|
|
Your browser does not support the video tag.
|
||
|
|
</video>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (file.mimeType === 'application/pdf') {
|
||
|
|
return (
|
||
|
|
<div className="relative">
|
||
|
|
<iframe
|
||
|
|
src={`${url}#toolbar=0`}
|
||
|
|
className="w-full h-[600px]"
|
||
|
|
title={file.fileName}
|
||
|
|
/>
|
||
|
|
<Button
|
||
|
|
variant="secondary"
|
||
|
|
size="sm"
|
||
|
|
className="absolute top-2 right-2"
|
||
|
|
asChild
|
||
|
|
>
|
||
|
|
<a href={url} target="_blank" rel="noopener noreferrer">
|
||
|
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||
|
|
Open in new tab
|
||
|
|
</a>
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||
|
|
Preview not available for this file type
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Compact file list for smaller views
|
||
|
|
export function FileList({ files, className }: FileViewerProps) {
|
||
|
|
if (files.length === 0) return null
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className={cn('space-y-2', className)}>
|
||
|
|
{files.map((file) => {
|
||
|
|
const Icon = getFileIcon(file.fileType, file.mimeType)
|
||
|
|
return (
|
||
|
|
<CompactFileItem key={file.id} file={file} />
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function CompactFileItem({ file }: { file: ProjectFile }) {
|
||
|
|
const [loading, setLoading] = useState(false)
|
||
|
|
const Icon = getFileIcon(file.fileType, file.mimeType)
|
||
|
|
|
||
|
|
const { refetch } = trpc.file.getDownloadUrl.useQuery(
|
||
|
|
{ bucket: file.bucket, objectKey: file.objectKey },
|
||
|
|
{ enabled: false }
|
||
|
|
)
|
||
|
|
|
||
|
|
const handleClick = async () => {
|
||
|
|
setLoading(true)
|
||
|
|
try {
|
||
|
|
const result = await refetch()
|
||
|
|
if (result.data?.url) {
|
||
|
|
window.open(result.data.url, '_blank')
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Failed to get download URL:', error)
|
||
|
|
} finally {
|
||
|
|
setLoading(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
onClick={handleClick}
|
||
|
|
disabled={loading}
|
||
|
|
className="flex w-full items-center gap-2 rounded-md border p-2 text-left hover:bg-muted transition-colors disabled:opacity-50"
|
||
|
|
>
|
||
|
|
{loading ? (
|
||
|
|
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
|
||
|
|
) : (
|
||
|
|
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||
|
|
)}
|
||
|
|
<span className="flex-1 truncate text-sm">{file.fileName}</span>
|
||
|
|
<span className="text-xs text-muted-foreground shrink-0">
|
||
|
|
{formatFileSize(file.size)}
|
||
|
|
</span>
|
||
|
|
</button>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export function FileViewerSkeleton() {
|
||
|
|
return (
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<Skeleton className="h-5 w-28" />
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-3">
|
||
|
|
{[1, 2, 3].map((i) => (
|
||
|
|
<div key={i} className="flex items-center gap-3 rounded-lg border p-3">
|
||
|
|
<Skeleton className="h-10 w-10 rounded-lg" />
|
||
|
|
<div className="flex-1 space-y-2">
|
||
|
|
<Skeleton className="h-4 w-48" />
|
||
|
|
<Skeleton className="h-3 w-24" />
|
||
|
|
</div>
|
||
|
|
<Skeleton className="h-9 w-20" />
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)
|
||
|
|
}
|