Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
344
src/components/shared/file-viewer.tsx
Normal file
344
src/components/shared/file-viewer.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user