mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
ui on new contrat page
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo, useRef } from "react";
|
||||
import React, { useState, useMemo, useRef, useEffect, useCallback } from "react";
|
||||
import {
|
||||
IFileUploadSetting,
|
||||
IFileUploadField,
|
||||
@@ -11,9 +11,15 @@ import {
|
||||
Trash2,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../button";
|
||||
import {
|
||||
FileViewerModal,
|
||||
isViewable,
|
||||
type ViewableFile,
|
||||
} from "../FileViewer";
|
||||
|
||||
export interface SmartFileInputProps {
|
||||
/** The settings object containing features and their upload fields config. */
|
||||
@@ -86,6 +92,43 @@ export function SmartFileInput({
|
||||
// File input refs for programmatic clicks in minimal variant
|
||||
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
|
||||
// The staged file currently open in the preview modal.
|
||||
const [previewFile, setPreviewFile] = useState<ViewableFile | null>(null);
|
||||
|
||||
// Object URLs minted for staged File objects (keyed by File identity) so the
|
||||
// preview modal + image thumbnails can render local files. Revoked on unmount.
|
||||
const objectUrls = useRef(new Map<File, string>());
|
||||
const urlForFile = useCallback((fileObj: File): string => {
|
||||
const cache = objectUrls.current;
|
||||
let url = cache.get(fileObj);
|
||||
if (!url) {
|
||||
url = URL.createObjectURL(fileObj);
|
||||
cache.set(fileObj, url);
|
||||
}
|
||||
return url;
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const cache = objectUrls.current;
|
||||
return () => {
|
||||
cache.forEach((url) => URL.revokeObjectURL(url));
|
||||
cache.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openPreview = (fileObj: File) => {
|
||||
setPreviewFile({
|
||||
name: fileObj.name,
|
||||
url: urlForFile(fileObj),
|
||||
mimeType: fileObj.type || null,
|
||||
});
|
||||
};
|
||||
|
||||
const isImage = (fileObj: File) =>
|
||||
fileObj.type.startsWith("image/") ||
|
||||
["png", "jpg", "jpeg", "webp", "gif", "bmp", "svg"].includes(
|
||||
fileObj.name.split(".").pop()?.toLowerCase() ?? "",
|
||||
);
|
||||
|
||||
// Memoize fields sorted by the order property (ascending)
|
||||
const sortedFields = useMemo(() => {
|
||||
return [...file.fields].sort((a, b) => a.order - b.order);
|
||||
@@ -286,55 +329,95 @@ export function SmartFileInput({
|
||||
{/* Selected Files List */}
|
||||
{currentFiles.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{currentFiles.map((fileObj, idx) => (
|
||||
<div
|
||||
key={`${fileObj.name}-${idx}`}
|
||||
className={cn(
|
||||
"flex items-center justify-between p-3 rounded-lg border bg-card transition shadow-2xs hover:shadow-xs",
|
||||
fieldError ? "border-destructive/30" : "border-border"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="p-2 bg-muted rounded-md flex items-center justify-center">
|
||||
<FileIcon name={fileObj.name} className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate max-w-[200px] md:max-w-md" title={fileObj.name}>
|
||||
{fileObj.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatBytes(fileObj.size)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 text-xs text-primary font-medium">
|
||||
<CheckCircle2 className="h-3 w-3" /> Ready
|
||||
</span>
|
||||
{currentFiles.map((fileObj, idx) => {
|
||||
const showThumb = isImage(fileObj);
|
||||
const canPreview =
|
||||
isViewable({ name: fileObj.name, mimeType: fileObj.type, url: "" });
|
||||
return (
|
||||
<div
|
||||
key={`${fileObj.name}-${idx}`}
|
||||
className={cn(
|
||||
"group/file flex items-center justify-between gap-3 p-2.5 pr-3 rounded-xl border bg-card transition-all duration-200 shadow-2xs hover:shadow-md hover:border-primary/40",
|
||||
fieldError ? "border-destructive/30" : "border-border"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{/* Thumbnail (images) or a tinted type tile */}
|
||||
{showThumb ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openPreview(fileObj)}
|
||||
className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg border border-border bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={`Preview ${fileObj.name}`}
|
||||
>
|
||||
<img
|
||||
src={urlForFile(fileObj)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover transition-transform duration-200 group-hover/file:scale-105"
|
||||
/>
|
||||
<span className="absolute inset-0 flex items-center justify-center bg-black/0 opacity-0 transition-all duration-200 group-hover/file:bg-black/35 group-hover/file:opacity-100">
|
||||
<Eye className="h-4 w-4 text-white" />
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<FileIcon name={fileObj.name} className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className="text-sm font-medium text-foreground truncate max-w-[180px] md:max-w-md"
|
||||
title={fileObj.name}
|
||||
>
|
||||
{fileObj.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatBytes(fileObj.size)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 text-xs text-primary font-medium">
|
||||
<CheckCircle2 className="h-3 w-3" /> Ready
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => removeFile(field.fileKey, idx)}
|
||||
className={cn(
|
||||
"p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors",
|
||||
disabled && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
aria-label={`Remove file ${fileObj.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Hidden inputs to represent file details in traditional form submissions */}
|
||||
<input
|
||||
type="hidden"
|
||||
name={field.isMultiple ? `${field.fileKey}[]` : field.fileKey}
|
||||
value={fileObj.name}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{canPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openPreview(fileObj)}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-primary/10 hover:text-primary"
|
||||
aria-label={`Preview ${fileObj.name}`}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Preview</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => removeFile(field.fileKey, idx)}
|
||||
className={cn(
|
||||
"p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors",
|
||||
disabled && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
aria-label={`Remove file ${fileObj.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hidden inputs to represent file details in traditional form submissions */}
|
||||
<input
|
||||
type="hidden"
|
||||
name={field.isMultiple ? `${field.fileKey}[]` : field.fileKey}
|
||||
value={fileObj.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -419,6 +502,12 @@ export function SmartFileInput({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<FileViewerModal
|
||||
open={previewFile !== null}
|
||||
file={previewFile}
|
||||
onClose={() => setPreviewFile(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user