feat(cloudpage) - wip - view and download feat added, upload ui size info added

This commit is contained in:
2025-09-30 02:50:10 +05:30
parent f7032e37c8
commit d2d3d1bbb1
6 changed files with 633 additions and 60 deletions

View File

@@ -8,18 +8,92 @@ interface FileUploadZoneProps {
onFileUpload: (file: File) => void;
isUploading: boolean;
acceptedFileTypes?: string;
// OPTIONAL: fallback max file size MB
maxFileSizeMB?: number;
// OPTIONAL: per-type size map in MB, e.g. { "application/pdf": 10, "image/*": 2 }
maxFileSizeByType?: Record<string, number>;
}
export function FileUploadZone({
onFileUpload,
isUploading,
acceptedFileTypes = "application/pdf",
maxFileSizeMB = 10, // default 10mb
maxFileSizeByType,
}: FileUploadZoneProps) {
const { toast } = useToast();
const [isDragging, setIsDragging] = useState(false);
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// helpers
const mbToBytes = (mb: number) => Math.round(mb * 1024 * 1024);
const humanSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
};
const parsedAccept = acceptedFileTypes
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
const allowedBytesForMime = (mime: string | undefined) => {
if (!mime) return mbToBytes(maxFileSizeMB);
if (maxFileSizeByType && maxFileSizeByType[mime] != null) {
return mbToBytes(maxFileSizeByType[mime]!);
}
const parts = mime.split("/");
if (parts.length === 2) {
const wildcard = `${parts[0]}/*`;
if (maxFileSizeByType && maxFileSizeByType[wildcard] != null) {
return mbToBytes(maxFileSizeByType[wildcard]!);
}
}
return mbToBytes(maxFileSizeMB);
};
const isMimeAllowed = (fileType: string | undefined) => {
if (!fileType) return false;
const ft = fileType.toLowerCase();
for (const a of parsedAccept) {
if (a === ft) return true;
if (a === "*/*") return true;
if (a.endsWith("/*")) {
const major = a.split("/")[0];
if (ft.startsWith(`${major}/`)) return true;
}
}
return false;
};
const validateFile = (file: File) => {
// <<< CHANGED: use isMimeAllowed instead of strict include
if (!isMimeAllowed(file.type)) {
toast({
title: "Invalid file type",
description: "Please upload a supported file type.",
variant: "destructive",
});
return false;
}
const allowedBytes = allowedBytesForMime(file.type);
if (file.size > allowedBytes) {
toast({
title: "File too large",
description: `${file.name} is ${humanSize(file.size)} — max for this type is ${humanSize(
allowedBytes
)}`,
variant: "destructive",
});
return false;
}
return true;
};
const handleDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
@@ -43,30 +117,6 @@ export function FileUploadZone({
[isDragging]
);
const validateFile = (file: File) => {
// Check file type
if (!file.type.match(acceptedFileTypes)) {
toast({
title: "Invalid file type",
description: "Please upload a PDF file.",
variant: "destructive",
});
return false;
}
// Check file size (limit to 5MB)
if (file.size > 5 * 1024 * 1024) {
toast({
title: "File too large",
description: "File size should be less than 5MB.",
variant: "destructive",
});
return false;
}
return true;
};
const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
@@ -109,6 +159,20 @@ export function FileUploadZone({
setUploadedFile(null);
};
const typeBadges = parsedAccept.map((t) => {
const display =
t === "image/*"
? "Images"
: t.includes("/")
? t!.split("/")[1]!.toUpperCase()
: t.toUpperCase();
const mb =
(maxFileSizeByType &&
(maxFileSizeByType[t] ?? maxFileSizeByType[`${t.split("/")[0]}/*`])) ??
maxFileSizeMB;
return { key: t, label: `${display}${mb} MB`, mb };
});
return (
<div className="w-full">
<input
@@ -159,7 +223,10 @@ export function FileUploadZone({
<div>
<p className="font-medium text-primary">{uploadedFile.name}</p>
<p className="text-sm text-muted-foreground mt-1">
{(uploadedFile.size / 1024).toFixed(1)} KB
{humanSize(uploadedFile.size)} allowed{" "}
{humanSize(allowedBytesForMime(uploadedFile.type))}
{" • "}
{uploadedFile.type || "unknown"}
</p>
</div>
<p className="text-sm text-muted-foreground">
@@ -173,6 +240,17 @@ export function FileUploadZone({
<p className="font-medium text-primary">
Drag and drop a PDF file here
</p>
<div className="flex flex-wrap gap-2 justify-center mt-2">
{typeBadges.map((b) => (
<span
key={b.key}
className="text-xs px-2 py-1 rounded-full border bg-gray-50 text-gray-700"
title={b.label}
>
{b.label}
</span>
))}
</div>
<p className="text-sm text-muted-foreground mt-1">
Or click to browse files
</p>
@@ -188,7 +266,7 @@ export function FileUploadZone({
Browse files
</Button>
<p className="text-xs text-muted-foreground">
Accepts PDF files up to 5MB
Accepts {acceptedFileTypes} max {maxFileSizeMB} MB (default)
</p>
</div>
)}

View File

@@ -21,6 +21,10 @@ interface FileUploadZoneProps {
isUploading?: boolean;
acceptedFileTypes?: string;
maxFiles?: number;
//OPTIONAL: default max per-file (MB) when no per-type rule matches
maxFileSizeMB?: number;
//OPTIONAL: per-mime (or wildcard) map in MB: { "application/pdf": 10, "image/*": 2 }
maxFileSizeByType?: Record<string, number>;
}
export const MultipleFileUploadZone = forwardRef<
@@ -33,6 +37,8 @@ export const MultipleFileUploadZone = forwardRef<
isUploading = false,
acceptedFileTypes = "application/pdf,image/jpeg,image/jpg,image/png,image/webp",
maxFiles = 10,
maxFileSizeMB = 10, // default fallback per-file size (MB)
maxFileSizeByType, // optional per-type overrides, e.g. { "application/pdf": 10, "image/*": 2 }
},
ref
) => {
@@ -41,24 +47,72 @@ export const MultipleFileUploadZone = forwardRef<
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const allowedTypes = acceptedFileTypes
const parsedAccept = acceptedFileTypes
.split(",")
.map((type) => type.trim());
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
// helper: convert MB -> bytes
const mbToBytes = (mb: number) => Math.round(mb * 1024 * 1024);
// human readable size
const humanSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
};
// Determine allowed bytes for a given file mime:
// Priority: exact mime -> wildcard major/* -> default maxFileSizeMB
const allowedBytesForMime = (mime: string | undefined) => {
if (!mime) return mbToBytes(maxFileSizeMB);
// exact match
if (maxFileSizeByType && maxFileSizeByType[mime] != null) {
return mbToBytes(maxFileSizeByType[mime]!);
}
// wildcard match: image/*, audio/* etc.
const parts = mime.split("/");
if (parts.length === 2) {
const wildcard = `${parts[0]}/*`;
if (maxFileSizeByType && maxFileSizeByType[wildcard] != null) {
return mbToBytes(maxFileSizeByType[wildcard]!);
}
}
// fallback default
return mbToBytes(maxFileSizeMB);
};
const isMimeAllowed = (fileType: string) => {
const ft = (fileType || "").toLowerCase();
for (const a of parsedAccept) {
if (a === ft) return true;
if (a === "*/*") return true;
if (a.endsWith("/*")) {
const major = a.split("/")[0];
if (ft.startsWith(`${major}/`)) return true;
}
}
return false;
};
// Validation uses allowedBytesForMime
const validateFile = (file: File) => {
if (!allowedTypes.includes(file.type)) {
if (!isMimeAllowed(file.type)) {
toast({
title: "Invalid file type",
description: "Only PDF and image files are allowed.",
description: "Only the allowed file types are permitted.",
variant: "destructive",
});
return false;
}
if (file.size > 5 * 1024 * 1024) {
const allowed = allowedBytesForMime(file.type);
if (file.size > allowed) {
toast({
title: "File too large",
description: "File size must be less than 5MB.",
description: `${file.name} is ${humanSize(
file.size
)} — max allowed for this type is ${humanSize(allowed)}.`,
variant: "destructive",
});
return false;
@@ -309,6 +363,9 @@ export const MultipleFileUploadZone = forwardRef<
className="flex justify-between items-center border-b pb-1"
>
<span className="text-sm">{file.name}</span>
<span className="text-xs text-muted-foreground">
{humanSize(file.size)} {file.type || "unknown"}
</span>
<button
className="ml-2 p-1 text-muted-foreground hover:text-red-500"
onClick={(e) => {
@@ -321,12 +378,63 @@ export const MultipleFileUploadZone = forwardRef<
</li>
))}
</ul>
{/* prominent per-type size badges */}
<div className="flex flex-wrap gap-2 justify-center mt-2">
{parsedAccept.map((t) => {
const display =
t === "image/*"
? "Images"
: t.includes("/")
? t!.split("/")[1]!.toUpperCase()
: t.toUpperCase();
const mb =
(maxFileSizeByType &&
(maxFileSizeByType[t] ??
maxFileSizeByType[`${t.split("/")[0]}/*`])) ??
maxFileSizeMB;
return (
<span
key={t}
className="text-xs px-2 py-1 rounded-full border bg-gray-50 text-gray-700"
title={`${display} — max ${mb} MB`}
>
{display} {mb} MB
</span>
);
})}
</div>
</div>
) : (
<div className="flex flex-col items-center gap-4">
<FilePlus className="h-12 w-12 text-primary/70" />
<div>
<p className="font-medium text-primary">{uploadTitle}</p>
{/* show same badges above file list so user sees limits after selecting */}
<div className="flex flex-wrap gap-2 justify-center mt-2">
{parsedAccept.map((t) => {
const display =
t === "image/*"
? "Images"
: t.includes("/")
? t!.split("/")[1]!.toUpperCase()
: t.toUpperCase();
const mb =
(maxFileSizeByType &&
(maxFileSizeByType[t] ??
maxFileSizeByType[`${t.split("/")[0]}/*`])) ??
maxFileSizeMB;
return (
<span
key={t + "-list"}
className="text-xs px-2 py-1 rounded-full border bg-gray-50 text-gray-700"
title={`${display} — max ${mb} MB`}
>
{display} {mb} MB
</span>
);
})}
</div>
<p className="text-sm text-muted-foreground mt-1">
Or click to browse files
</p>