Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
144
src/components/files/file-grid.tsx
Normal file
144
src/components/files/file-grid.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { Download, Eye, FileText, Film, Image, MoreHorizontal, Pencil, Sheet, Trash2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PREVIEWABLE_MIMES } from '@/lib/constants/file-validation';
|
||||
|
||||
export interface FileRow {
|
||||
id: string;
|
||||
filename: string;
|
||||
originalName: string;
|
||||
mimeType: string | null;
|
||||
sizeBytes: string | null;
|
||||
storagePath?: string;
|
||||
category: string | null;
|
||||
createdAt: string | Date;
|
||||
uploadedBy: string;
|
||||
}
|
||||
|
||||
interface FileGridProps {
|
||||
files: FileRow[];
|
||||
onDownload: (file: FileRow) => void;
|
||||
onPreview: (file: FileRow) => void;
|
||||
onRename: (file: FileRow) => void;
|
||||
onDelete: (file: FileRow) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: string | null): string {
|
||||
if (!bytes) return '';
|
||||
const n = Number(bytes);
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function FileIcon({ mimeType }: { mimeType: string | null }) {
|
||||
if (!mimeType) return <FileText className="h-8 w-8 text-muted-foreground" />;
|
||||
if (mimeType.startsWith('image/')) return <Image className="h-8 w-8 text-blue-500" />;
|
||||
if (mimeType === 'application/pdf') return <FileText className="h-8 w-8 text-red-500" />;
|
||||
if (
|
||||
mimeType === 'application/vnd.ms-excel' ||
|
||||
mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
|
||||
mimeType === 'text/csv'
|
||||
) {
|
||||
return <Sheet className="h-8 w-8 text-green-600" />;
|
||||
}
|
||||
if (mimeType.startsWith('video/')) return <Film className="h-8 w-8 text-purple-500" />;
|
||||
return <FileText className="h-8 w-8 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
export function FileGrid({
|
||||
files,
|
||||
onDownload,
|
||||
onPreview,
|
||||
onRename,
|
||||
onDelete,
|
||||
isLoading,
|
||||
}: FileGridProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-32 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
||||
<FileText className="h-10 w-10 mb-3 opacity-40" />
|
||||
<p className="text-sm font-medium">No files yet</p>
|
||||
<p className="text-xs mt-1">Upload files using the zone above</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="group relative rounded-lg border bg-card p-3 hover:border-primary/50 hover:shadow-sm transition-all"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileIcon mimeType={file.mimeType} />
|
||||
<p className="w-full truncate text-center text-xs font-medium" title={file.filename}>
|
||||
{file.filename}
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-0.5 text-[10px] text-muted-foreground">
|
||||
<span>{formatBytes(file.sizeBytes)}</span>
|
||||
<span>{format(new Date(file.createdAt), 'MMM d, yyyy')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-1 top-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem onClick={() => onDownload(file)}>
|
||||
<Download className="mr-2 h-3.5 w-3.5" />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
{file.mimeType && PREVIEWABLE_MIMES.has(file.mimeType) && (
|
||||
<DropdownMenuItem onClick={() => onPreview(file)}>
|
||||
<Eye className="mr-2 h-3.5 w-3.5" />
|
||||
Preview
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onRename(file)}>
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(file)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user