feat(cloudpage) - wip - view and download feat added, upload ui size info added
This commit is contained in:
@@ -1,7 +1,5 @@
|
|||||||
// src/routes/cloudStorage.routes.ts
|
|
||||||
import express, { Request, Response } from "express";
|
import express, { Request, Response } from "express";
|
||||||
// import storage from "../storage";
|
import storage from "../storage";
|
||||||
import { cloudStorage as storage } from "../storage/cloudStorage-storage";
|
|
||||||
import { serializeFile } from "../utils/prismaFileUtils";
|
import { serializeFile } from "../utils/prismaFileUtils";
|
||||||
import { CloudFolder } from "@repo/db/types";
|
import { CloudFolder } from "@repo/db/types";
|
||||||
|
|
||||||
@@ -242,6 +240,9 @@ router.get(
|
|||||||
PUT /files/:id { name?, mimeType?, folderId? }
|
PUT /files/:id { name?, mimeType?, folderId? }
|
||||||
DELETE /files/:id
|
DELETE /files/:id
|
||||||
*/
|
*/
|
||||||
|
const MAX_FILE_MB = 20;
|
||||||
|
const MAX_FILE_BYTES = MAX_FILE_MB * 1024 * 1024;
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
"/folders/:id/files",
|
"/folders/:id/files",
|
||||||
async (req: Request, res: Response): Promise<any> => {
|
async (req: Request, res: Response): Promise<any> => {
|
||||||
@@ -260,11 +261,25 @@ router.post(
|
|||||||
let expectedSize: bigint | null = null;
|
let expectedSize: bigint | null = null;
|
||||||
if (req.body.expectedSize != null) {
|
if (req.body.expectedSize != null) {
|
||||||
try {
|
try {
|
||||||
|
// coerce to BigInt safely
|
||||||
|
const asNum = Number(req.body.expectedSize);
|
||||||
|
if (!Number.isFinite(asNum) || asNum < 0) {
|
||||||
|
return sendError(res, 400, "Invalid expectedSize");
|
||||||
|
}
|
||||||
|
if (asNum > MAX_FILE_BYTES) {
|
||||||
|
// Payload Too Large
|
||||||
|
return sendError(
|
||||||
|
res,
|
||||||
|
413,
|
||||||
|
`File too large. Max allowed is ${MAX_FILE_MB} MB`
|
||||||
|
);
|
||||||
|
}
|
||||||
expectedSize = BigInt(String(req.body.expectedSize));
|
expectedSize = BigInt(String(req.body.expectedSize));
|
||||||
} catch {
|
} catch {
|
||||||
return sendError(res, 400, "Invalid expectedSize");
|
return sendError(res, 400, "Invalid expectedSize");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let totalChunks: number | null = null;
|
let totalChunks: number | null = null;
|
||||||
if (req.body.totalChunks != null) {
|
if (req.body.totalChunks != null) {
|
||||||
const tc = Number(req.body.totalChunks);
|
const tc = Number(req.body.totalChunks);
|
||||||
@@ -308,23 +323,20 @@ router.post(
|
|||||||
return sendError(res, 400, "Invalid seq");
|
return sendError(res, 400, "Invalid seq");
|
||||||
|
|
||||||
const body = req.body as Buffer;
|
const body = req.body as Buffer;
|
||||||
console.log(
|
|
||||||
`[chunk upload] fileId=${id} seq=${seq} contentType=${String(req.headers["content-type"])} bodyIsBuffer=${Buffer.isBuffer(body)} bodyLength=${body?.length ?? 0}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!body || !(body instanceof Buffer)) {
|
if (!body || !(body instanceof Buffer)) {
|
||||||
return sendError(res, 400, "Expected raw binary body (Buffer)");
|
return sendError(res, 400, "Expected raw binary body (Buffer)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// strict size guard: any single chunk must not exceed MAX_FILE_BYTES
|
||||||
|
if (body.length > MAX_FILE_BYTES) {
|
||||||
|
return sendError(res, 413, `Chunk size exceeds ${MAX_FILE_MB} MB limit`);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await storage.appendFileChunk(id, seq, body);
|
await storage.appendFileChunk(id, seq, body);
|
||||||
return res.json({ error: false, data: { fileId: id, seq } });
|
return res.json({ error: false, data: { fileId: id, seq } });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(
|
|
||||||
"[chunk upload] appendFileChunk failed:",
|
|
||||||
err && (err.stack || err.message || err)
|
|
||||||
);
|
|
||||||
|
|
||||||
return sendError(res, 500, "Failed to add chunk");
|
return sendError(res, 500, "Failed to add chunk");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,6 +351,28 @@ router.post(
|
|||||||
return sendError(res, 400, "Invalid file id");
|
return sendError(res, 400, "Invalid file id");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Ask storage for the file (includes chunks in your implementation)
|
||||||
|
const file = await storage.getFile(id);
|
||||||
|
if (!file) return sendError(res, 404, "File not found");
|
||||||
|
|
||||||
|
// Sum chunks' sizes (storage.getFile returns chunks ordered by seq in your impl)
|
||||||
|
const chunks = (file as any).chunks ?? [];
|
||||||
|
if (!chunks.length) return sendError(res, 400, "No chunks uploaded");
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
for (const c of chunks) {
|
||||||
|
// c.data is Bytes / Buffer-like
|
||||||
|
total += c.data.length;
|
||||||
|
// early bailout
|
||||||
|
if (total > MAX_FILE_BYTES) {
|
||||||
|
return sendError(
|
||||||
|
res,
|
||||||
|
413,
|
||||||
|
`Assembled file is too large (${Math.round(total / 1024 / 1024)} MB). Max allowed is ${MAX_FILE_MB} MB.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await storage.finalizeFileUpload(id);
|
const result = await storage.finalizeFileUpload(id);
|
||||||
return res.json({ error: false, data: result });
|
return res.json({ error: false, data: result });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -384,9 +418,56 @@ router.delete(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ---------- Download (stream) ----------
|
/* GET /files/:id -> return serialized metadata (used by preview modal) */
|
||||||
GET /files/:id/download
|
router.get("/files/:id", async (req: Request, res: Response): Promise<any> => {
|
||||||
*/
|
const id = Number.parseInt(req.params.id ?? "", 10);
|
||||||
|
if (!Number.isInteger(id) || id <= 0)
|
||||||
|
return sendError(res, 400, "Invalid file id");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = await storage.getFile(id);
|
||||||
|
if (!file) return sendError(res, 404, "File not found");
|
||||||
|
return res.json({ error: false, data: serializeFile(file as any) });
|
||||||
|
} catch (err) {
|
||||||
|
return sendError(res, 500, "Failed to load file");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* GET /files/:id/content -> stream file with inline disposition for preview */
|
||||||
|
router.get(
|
||||||
|
"/files/:id/content",
|
||||||
|
async (req: Request, res: Response): Promise<any> => {
|
||||||
|
const id = Number.parseInt(req.params.id ?? "", 10);
|
||||||
|
if (!Number.isInteger(id) || id <= 0)
|
||||||
|
return sendError(res, 400, "Invalid file id");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = await storage.getFile(id);
|
||||||
|
if (!file) return sendError(res, 404, "File not found");
|
||||||
|
|
||||||
|
const filename = (file.name ?? `file-${(file as any).id}`).replace(
|
||||||
|
/["\\]/g,
|
||||||
|
""
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((file as any).mimeType)
|
||||||
|
res.setHeader("Content-Type", (file as any).mimeType);
|
||||||
|
// NOTE: inline instead of attachment so browser can render (images, pdfs)
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`inline; filename="${encodeURIComponent(filename)}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
await storage.streamFileTo(res, id);
|
||||||
|
|
||||||
|
if (!res.writableEnded) res.end();
|
||||||
|
} catch (err) {
|
||||||
|
if (res.headersSent) return res.end();
|
||||||
|
return sendError(res, 500, "Failed to stream file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
/* GET /files/:id/download */
|
||||||
router.get(
|
router.get(
|
||||||
"/files/:id/download",
|
"/files/:id/download",
|
||||||
async (req: Request, res: Response): Promise<any> => {
|
async (req: Request, res: Response): Promise<any> => {
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export interface IStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------- Implementation ------------------------------- */
|
/* ------------------------------- Implementation ------------------------------- */
|
||||||
export const cloudStorage: IStorage = {
|
export const cloudStorageStorage: IStorage = {
|
||||||
// --- FOLDERS ---
|
// --- FOLDERS ---
|
||||||
async getFolder(id: number) {
|
async getFolder(id: number) {
|
||||||
const folder = await db.cloudFolder.findUnique({
|
const folder = await db.cloudFolder.findUnique({
|
||||||
@@ -471,4 +471,4 @@ export const cloudStorage: IStorage = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default cloudStorage;
|
export default cloudStorageStorage;
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
import { Download, Maximize2, Minimize2, X } from "lucide-react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
fileId: number | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FilePreviewModal({ fileId, isOpen, onClose }: Props) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [meta, setMeta] = useState<any | null>(null);
|
||||||
|
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !fileId) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let createdUrl: string | null = null;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setMeta(null);
|
||||||
|
setBlobUrl(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const metaRes = await apiRequest(
|
||||||
|
"GET",
|
||||||
|
`/api/cloud-storage/files/${fileId}`
|
||||||
|
);
|
||||||
|
const metaJson = await metaRes.json();
|
||||||
|
if (!metaRes.ok) {
|
||||||
|
throw new Error(metaJson?.message || "Failed to load file metadata");
|
||||||
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
setMeta(metaJson.data);
|
||||||
|
|
||||||
|
const contentRes = await apiRequest(
|
||||||
|
"GET",
|
||||||
|
`/api/cloud-storage/files/${fileId}/content`
|
||||||
|
);
|
||||||
|
if (!contentRes.ok) {
|
||||||
|
let msg = `Preview request failed (${contentRes.status})`;
|
||||||
|
try {
|
||||||
|
const j = await contentRes.json();
|
||||||
|
msg = j?.message ?? msg;
|
||||||
|
} catch (e) {}
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
const blob = await contentRes.blob();
|
||||||
|
if (cancelled) return;
|
||||||
|
createdUrl = URL.createObjectURL(blob);
|
||||||
|
setBlobUrl(createdUrl);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!cancelled) setError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (createdUrl) {
|
||||||
|
URL.revokeObjectURL(createdUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isOpen, fileId]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const mime = meta?.mimeType ?? "";
|
||||||
|
|
||||||
|
async function handleDownload() {
|
||||||
|
if (!fileId) return;
|
||||||
|
try {
|
||||||
|
const res = await apiRequest(
|
||||||
|
"GET",
|
||||||
|
`/api/cloud-storage/files/${fileId}/download`
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(j?.message || `Download failed (${res.status})`);
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = meta?.name ?? `file-${fileId}`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast({
|
||||||
|
title: "Download failed",
|
||||||
|
description: err?.message ?? String(err),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// container sizing classes
|
||||||
|
const containerBase =
|
||||||
|
"bg-white rounded-md p-3 flex flex-col overflow-hidden shadow-xl";
|
||||||
|
const sizeClass = isFullscreen
|
||||||
|
? "w-[95vw] h-[95vh]"
|
||||||
|
: "w-[min(1200px,95vw)] h-[85vh]";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 p-4">
|
||||||
|
<div className={`${containerBase} ${sizeClass} max-w-full max-h-full`}>
|
||||||
|
{/* header */}
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-3 pb-2 border-b">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-lg font-semibold truncate">
|
||||||
|
{meta?.name ?? "Preview"}
|
||||||
|
</h3>
|
||||||
|
<div className="text-sm text-gray-500 truncate">
|
||||||
|
{meta?.mimeType ?? ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsFullscreen((s) => !s)}
|
||||||
|
title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
|
||||||
|
className="p-2 rounded hover:bg-gray-100"
|
||||||
|
aria-label="Toggle fullscreen"
|
||||||
|
>
|
||||||
|
{isFullscreen ? (
|
||||||
|
<Minimize2 className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Maximize2 className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<Button variant="ghost" onClick={handleDownload}>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onClose}>
|
||||||
|
{" "}
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* body */}
|
||||||
|
<div className="flex-1 overflow-auto mt-3">
|
||||||
|
{/* loading / error */}
|
||||||
|
{loading && (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
Loading preview…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <div className="text-red-600">{error}</div>}
|
||||||
|
|
||||||
|
{/* image */}
|
||||||
|
{!loading && !error && blobUrl && mime.startsWith("image/") && (
|
||||||
|
<div className="flex items-center justify-center w-full h-full">
|
||||||
|
<img
|
||||||
|
src={blobUrl}
|
||||||
|
alt={meta?.name}
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
style={{ maxHeight: "calc(100vh - 200px)" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* pdf */}
|
||||||
|
{!loading &&
|
||||||
|
!error &&
|
||||||
|
blobUrl &&
|
||||||
|
(mime === "application/pdf" || mime.endsWith("/pdf")) && (
|
||||||
|
<div className="w-full h-full">
|
||||||
|
<iframe
|
||||||
|
src={blobUrl}
|
||||||
|
title={meta?.name}
|
||||||
|
className="w-full h-full border-0"
|
||||||
|
style={{ minHeight: 400 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* fallback */}
|
||||||
|
{!loading &&
|
||||||
|
!error &&
|
||||||
|
blobUrl &&
|
||||||
|
!mime.startsWith("image/") &&
|
||||||
|
!mime.includes("pdf") && (
|
||||||
|
<div className="p-4">
|
||||||
|
<p>Preview not available for this file type.</p>
|
||||||
|
<p className="mt-2">
|
||||||
|
<a
|
||||||
|
href={blobUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline"
|
||||||
|
>
|
||||||
|
Open raw
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
import { getPageNumbers } from "@/utils/pageNumberGenerator";
|
import { getPageNumbers } from "@/utils/pageNumberGenerator";
|
||||||
import { NewFolderModal } from "./new-folder-modal";
|
import { NewFolderModal } from "./new-folder-modal";
|
||||||
import { toast } from "@/hooks/use-toast";
|
import { toast } from "@/hooks/use-toast";
|
||||||
import { Description } from "@radix-ui/react-toast";
|
import FilePreviewModal from "./file-preview-modal";
|
||||||
|
|
||||||
export type FilesSectionProps = {
|
export type FilesSectionProps = {
|
||||||
parentId: number | null;
|
parentId: number | null;
|
||||||
@@ -45,6 +45,8 @@ export type FilesSectionProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const FILES_LIMIT_DEFAULT = 20;
|
const FILES_LIMIT_DEFAULT = 20;
|
||||||
|
const MAX_FILE_MB = 10;
|
||||||
|
const MAX_FILE_BYTES = MAX_FILE_MB * 1024 * 1024;
|
||||||
|
|
||||||
function fileIcon(mime?: string) {
|
function fileIcon(mime?: string) {
|
||||||
if (!mime) return <FileIcon className="h-6 w-6" />;
|
if (!mime) return <FileIcon className="h-6 w-6" />;
|
||||||
@@ -70,6 +72,7 @@ export default function FilesSection({
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
// upload modal and ref
|
// upload modal and ref
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||||
const uploadRef = useRef<any>(null);
|
const uploadRef = useRef<any>(null);
|
||||||
|
|
||||||
@@ -78,9 +81,14 @@ export default function FilesSection({
|
|||||||
const [renameTargetId, setRenameTargetId] = useState<number | null>(null);
|
const [renameTargetId, setRenameTargetId] = useState<number | null>(null);
|
||||||
const [renameInitial, setRenameInitial] = useState("");
|
const [renameInitial, setRenameInitial] = useState("");
|
||||||
|
|
||||||
|
// delete dialog
|
||||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<CloudFile | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<CloudFile | null>(null);
|
||||||
|
|
||||||
|
// preview modal
|
||||||
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||||
|
const [previewFileId, setPreviewFileId] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPage(currentPage);
|
loadPage(currentPage);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -198,20 +206,74 @@ export default function FilesSection({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// download
|
// download (context menu) - (fetch bytes from backend host via wrapper)
|
||||||
function handleDownload(file: CloudFile) {
|
async function handleDownload(file: CloudFile) {
|
||||||
// Open download endpoint in new tab so the browser handles attachment headers
|
try {
|
||||||
const url = `/api/cloud-storage/files/${file.id}/download`;
|
const res = await apiRequest(
|
||||||
window.open(url, "_blank");
|
"GET",
|
||||||
contextMenu.hideAll();
|
`/api/cloud-storage/files/${file.id}/download`
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(j?.message || `Download failed (${res.status})`);
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = file.name ?? `file-${file.id}`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
|
||||||
|
// revoke after a bit
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||||
|
contextMenu.hideAll();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast({
|
||||||
|
title: "Download failed",
|
||||||
|
description: err?.message ?? String(err),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// upload: get files from MultipleFileUploadZone (imperative handle)
|
// upload: get files from MultipleFileUploadZone (imperative handle)
|
||||||
async function handleUploadSubmit() {
|
async function handleUploadSubmit() {
|
||||||
const files: File[] = uploadRef.current?.getFiles?.() ?? [];
|
const files: File[] = uploadRef.current?.getFiles?.() ?? [];
|
||||||
if (!files.length) return;
|
if (!files.length) {
|
||||||
|
toast({
|
||||||
|
title: "No files selected",
|
||||||
|
description: "Please choose files to upload before clicking Upload.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
|
||||||
|
// pre-check all files and show errors / skip too-large files
|
||||||
|
const oversized = files.filter((f) => f.size > MAX_FILE_BYTES);
|
||||||
|
if (oversized.length) {
|
||||||
|
oversized.slice(0, 5).forEach((f) =>
|
||||||
|
toast({
|
||||||
|
title: "File too large",
|
||||||
|
description: `${f.name} is ${Math.round(f.size / 1024 / 1024)} MB — max ${MAX_FILE_MB} MB allowed.`,
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// Remove oversized files from the upload list (upload the rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toUpload = files.filter((f) => f.size <= MAX_FILE_BYTES);
|
||||||
|
if (toUpload.length === 0) {
|
||||||
|
// nothing to upload
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const f of files) {
|
for (const f of toUpload) {
|
||||||
const fid = parentId === null ? "null" : String(parentId);
|
const fid = parentId === null ? "null" : String(parentId);
|
||||||
const initRes = await apiRequest(
|
const initRes = await apiRequest(
|
||||||
"POST",
|
"POST",
|
||||||
@@ -254,13 +316,23 @@ export default function FilesSection({
|
|||||||
description: err?.message ?? String(err),
|
description: err?.message ?? String(err),
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
const startItem = total === 0 ? 0 : (currentPage - 1) * pageSize + 1;
|
const startItem = total === 0 ? 0 : (currentPage - 1) * pageSize + 1;
|
||||||
const endItem = Math.min(total, currentPage * pageSize);
|
const endItem = Math.min(total, currentPage * pageSize);
|
||||||
|
|
||||||
|
// open preview (single click)
|
||||||
|
function openPreview(file: CloudFile) {
|
||||||
|
setPreviewFileId(Number(file.id));
|
||||||
|
setIsPreviewOpen(true);
|
||||||
|
contextMenu.hideAll();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
@@ -290,7 +362,7 @@ export default function FilesSection({
|
|||||||
key={file.id}
|
key={file.id}
|
||||||
className="p-3 rounded border hover:bg-gray-50 cursor-pointer"
|
className="p-3 rounded border hover:bg-gray-50 cursor-pointer"
|
||||||
onContextMenu={(e) => showMenu(e, file)}
|
onContextMenu={(e) => showMenu(e, file)}
|
||||||
onDoubleClick={() => onFileOpen?.(Number(file.id))}
|
onClick={() => openPreview(file)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="h-10 w-10 text-gray-500 mb-2 flex items-center justify-center">
|
<div className="h-10 w-10 text-gray-500 mb-2 flex items-center justify-center">
|
||||||
@@ -405,12 +477,21 @@ export default function FilesSection({
|
|||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40">
|
||||||
<div className="bg-white p-6 rounded-md w-[90%] max-w-2xl">
|
<div className="bg-white p-6 rounded-md w-[90%] max-w-2xl">
|
||||||
<h3 className="text-lg font-semibold mb-4">Upload files</h3>
|
<h3 className="text-lg font-semibold mb-4">Upload files</h3>
|
||||||
<MultipleFileUploadZone ref={uploadRef} />
|
<MultipleFileUploadZone
|
||||||
|
ref={uploadRef}
|
||||||
|
acceptedFileTypes="application/pdf,image/*"
|
||||||
|
maxFiles={10}
|
||||||
|
maxFileSizeMB={10}
|
||||||
|
maxFileSizeByType={{ "application/pdf": 10, "image/*": 5 }}
|
||||||
|
isUploading={uploading}
|
||||||
|
/>
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
<Button variant="ghost" onClick={() => setIsUploadOpen(false)}>
|
<Button variant="ghost" onClick={() => setIsUploadOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleUploadSubmit}>Upload</Button>
|
<Button onClick={handleUploadSubmit} disabled={uploading}>
|
||||||
|
{uploading ? "Uploading..." : "Upload"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -429,6 +510,15 @@ export default function FilesSection({
|
|||||||
onSubmit={submitRename}
|
onSubmit={submitRename}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* FIle Preview Modal */}
|
||||||
|
<FilePreviewModal
|
||||||
|
fileId={previewFileId}
|
||||||
|
isOpen={isPreviewOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsPreviewOpen(false);
|
||||||
|
setPreviewFileId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/* delete confirm */}
|
{/* delete confirm */}
|
||||||
<DeleteConfirmationDialog
|
<DeleteConfirmationDialog
|
||||||
isOpen={isDeleteOpen}
|
isOpen={isDeleteOpen}
|
||||||
|
|||||||
@@ -8,18 +8,92 @@ interface FileUploadZoneProps {
|
|||||||
onFileUpload: (file: File) => void;
|
onFileUpload: (file: File) => void;
|
||||||
isUploading: boolean;
|
isUploading: boolean;
|
||||||
acceptedFileTypes?: string;
|
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({
|
export function FileUploadZone({
|
||||||
onFileUpload,
|
onFileUpload,
|
||||||
isUploading,
|
isUploading,
|
||||||
acceptedFileTypes = "application/pdf",
|
acceptedFileTypes = "application/pdf",
|
||||||
|
maxFileSizeMB = 10, // default 10mb
|
||||||
|
maxFileSizeByType,
|
||||||
}: FileUploadZoneProps) {
|
}: FileUploadZoneProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(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>) => {
|
const handleDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -43,30 +117,6 @@ export function FileUploadZone({
|
|||||||
[isDragging]
|
[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(
|
const handleDrop = useCallback(
|
||||||
(e: React.DragEvent<HTMLDivElement>) => {
|
(e: React.DragEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -109,6 +159,20 @@ export function FileUploadZone({
|
|||||||
setUploadedFile(null);
|
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 (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<input
|
<input
|
||||||
@@ -159,7 +223,10 @@ export function FileUploadZone({
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-primary">{uploadedFile.name}</p>
|
<p className="font-medium text-primary">{uploadedFile.name}</p>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -173,6 +240,17 @@ export function FileUploadZone({
|
|||||||
<p className="font-medium text-primary">
|
<p className="font-medium text-primary">
|
||||||
Drag and drop a PDF file here
|
Drag and drop a PDF file here
|
||||||
</p>
|
</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">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Or click to browse files
|
Or click to browse files
|
||||||
</p>
|
</p>
|
||||||
@@ -188,7 +266,7 @@ export function FileUploadZone({
|
|||||||
Browse files
|
Browse files
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Accepts PDF files up to 5MB
|
Accepts {acceptedFileTypes} — max {maxFileSizeMB} MB (default)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ interface FileUploadZoneProps {
|
|||||||
isUploading?: boolean;
|
isUploading?: boolean;
|
||||||
acceptedFileTypes?: string;
|
acceptedFileTypes?: string;
|
||||||
maxFiles?: number;
|
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<
|
export const MultipleFileUploadZone = forwardRef<
|
||||||
@@ -33,6 +37,8 @@ export const MultipleFileUploadZone = forwardRef<
|
|||||||
isUploading = false,
|
isUploading = false,
|
||||||
acceptedFileTypes = "application/pdf,image/jpeg,image/jpg,image/png,image/webp",
|
acceptedFileTypes = "application/pdf,image/jpeg,image/jpg,image/png,image/webp",
|
||||||
maxFiles = 10,
|
maxFiles = 10,
|
||||||
|
maxFileSizeMB = 10, // default fallback per-file size (MB)
|
||||||
|
maxFileSizeByType, // optional per-type overrides, e.g. { "application/pdf": 10, "image/*": 2 }
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
@@ -41,24 +47,72 @@ export const MultipleFileUploadZone = forwardRef<
|
|||||||
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
|
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const allowedTypes = acceptedFileTypes
|
const parsedAccept = acceptedFileTypes
|
||||||
.split(",")
|
.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) => {
|
const validateFile = (file: File) => {
|
||||||
if (!allowedTypes.includes(file.type)) {
|
if (!isMimeAllowed(file.type)) {
|
||||||
toast({
|
toast({
|
||||||
title: "Invalid file type",
|
title: "Invalid file type",
|
||||||
description: "Only PDF and image files are allowed.",
|
description: "Only the allowed file types are permitted.",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
const allowed = allowedBytesForMime(file.type);
|
||||||
|
if (file.size > allowed) {
|
||||||
toast({
|
toast({
|
||||||
title: "File too large",
|
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",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
@@ -309,6 +363,9 @@ export const MultipleFileUploadZone = forwardRef<
|
|||||||
className="flex justify-between items-center border-b pb-1"
|
className="flex justify-between items-center border-b pb-1"
|
||||||
>
|
>
|
||||||
<span className="text-sm">{file.name}</span>
|
<span className="text-sm">{file.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{humanSize(file.size)} • {file.type || "unknown"}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
className="ml-2 p-1 text-muted-foreground hover:text-red-500"
|
className="ml-2 p-1 text-muted-foreground hover:text-red-500"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -321,12 +378,63 @@ export const MultipleFileUploadZone = forwardRef<
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<FilePlus className="h-12 w-12 text-primary/70" />
|
<FilePlus className="h-12 w-12 text-primary/70" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-primary">{uploadTitle}</p>
|
<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">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Or click to browse files
|
Or click to browse files
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user