09a24233bb
- Fix login button (return success instead of ok) - Add UAT autocomplete with NFD-normalized search (3186 entries) - Add export-bundle API: base mode (terenuri+cladiri) + magic mode (enriched parcels) - Add export-layer-gpkg API: individual layer GPKG download - Add gpkg-export service: ogr2ogr with GeoJSON fallback - Add reproject service: EPSG:3844 projection support - Add magic-mode methods to eterra-client (immApps, folosinte, immovableList, docs, parcelDetails) - Rewrite UI: 3-tab layout (Export/Catalog/Search), progress tracking, phase trail
8.3 KiB
8.3 KiB
Prompt: Înlocuiește sistemul actual de preview documente atașate cu QuickLook-style Preview
Copiază tot conținutul de mai jos și dă-l ca instrucțiune directă modelului AI.
Înlocuiește complet sistemul actual de previzualizare a documentelor atașate cu un preview fullscreen inspirat de macOS Quick Look. Componentă React standalone, un singur fișier, fără dependențe externe — doar React hooks, lucide-react icons, Tailwind CSS, și un Button component din UI library.
Arhitectură
Un singur component exportat: AttachmentPreview
Props:
interface AttachmentPreviewProps {
/** Array cu fișierele previewable (filtrate în prealabil) */
attachments: Attachment[];
/** Indexul inițial de afișat */
initialIndex: number;
/** Dacă modal-ul e deschis */
open: boolean;
/** Callback la închidere */
onClose: () => void;
}
interface Attachment {
id: string;
name: string;
data: string; // base64 data URI (data:image/jpeg;base64,... sau data:application/pdf;base64,...)
type: string; // MIME type: "image/jpeg", "application/pdf", etc.
size: number; // bytes
}
Pattern de integrare (în componenta părinte):
// Folosește key={previewIndex} ca să forțeze remount-ul la fiecare deschidere nouă.
// Asta resetează zoom, pan, currentIndex fără setState în effects.
{
previewIndex !== null && (
<AttachmentPreview
key={previewIndex}
attachments={previewableAttachments}
initialIndex={previewIndex}
open
onClose={() => setPreviewIndex(null)}
/>
);
}
Helper exportat: getPreviewableAttachments(attachments)
Filtrează array-ul la atașamentele care pot fi previewate (au data non-empty, MIME type = image/* sau application/pdf).
Structura UI (3 zone fixe)
1. Top Bar (shrink-0)
- Stânga: icon fișier + filename (truncate) + dimensiune KB + extensie + "N / M" counter (dacă sunt multiple fișiere)
- Dreapta: [Zoom− | 100% | Zoom+] (doar pentru imagini) | Print | Download | Close(X)
- Separatoare vizuale între grupuri de butoane:
div.w-px.h-5.bg-white/20.mx-1
2. Content Area (flex-1)
- Container:
fixed inset-0 z-[100],bg-black/90 backdrop-blur-sm - Imagini:
<img>cutransform: translate(panX, panY) scale(zoom),transition-transform duration-150,draggable={false},select-none - PDF-uri:
<iframe src={blobUrl} className="w-full h-full border-0">— folosește blob URL, NU data: URI direct - Fallback (tip nesuportat): icon mare + text + buton Download
- Săgeți navigare: cercuri absolute
left-3/right-3 top-1/2, cue.stopPropagation()pe click
3. Bottom Thumbnails (shrink-0, doar când sunt multiple fișiere)
overflow-x-auto,gap-2- Imagini:
<img className="h-10 w-14 object-cover rounded"> - PDF-uri:
div.h-10.w-14cu FileText icon pe fundalbg-white/10 - Selectat:
border-white/80 ring-1 ring-white/30; neselectat:opacity-50 hover:opacity-80
Detalii tehnice critice
PDF rendering via Blob URL (NU data: URI)
Data URI-urile nu funcționează bine în iframe-uri. Convertește base64 la Blob:
const pdfBlobUrl = useMemo(() => {
if (!isPdf || !att?.data) return null;
try {
const base64 = att.data.split(",")[1]; // strip "data:application/pdf;base64,"
if (!base64) return null;
const bytes = atob(base64);
const arr = new Uint8Array(bytes.length);
for (let i = 0; i < bytes.length; i++) arr[i] = bytes.charCodeAt(i);
const blob = new Blob([arr], { type: "application/pdf" });
return URL.createObjectURL(blob);
} catch {
return null;
}
}, [isPdf, att?.data]);
// IMPORTANT: revocă blob URL la cleanup ca să nu existe memory leak
useEffect(() => {
return () => {
if (pdfBlobUrl) URL.revokeObjectURL(pdfBlobUrl);
};
}, [pdfBlobUrl]);
Zoom (doar imagini)
- Range: 25% — 500%, step 25%
- Scroll wheel:
onWheelcue.preventDefault(),deltaY > 0= zoom out - Keyboard:
+/=zoom in,-zoom out,0reset la 100% - Click pe procentajul afișat resetează la 100%
Pan (doar imagini, doar când zoom > 1)
onMouseDown: salvează start position înuseRef(nu state! — ref e mai performant pentru drag)onMouseMove: calculează delta din refonMouseUp/onMouseLeave: stop panning- Cursor:
cursor-grabcând zoom > 1,cursor-grabbingcând panning activ isPanninge singura stare React; coordonatele start sunt în ref:useRef({ x: 0, y: 0, panX: 0, panY: 0 })
- PDF: creează iframe ascuns cu
src=blobUrl,iframe.onload = () => iframe.contentWindow.print(), cleanup cusetTimeout(() => document.body.removeChild(iframe), 1000) - Imagine:
window.open("", "_blank")cu HTML inline care conține<img>,w.onload = () => w.print()
Download
const a = document.createElement("a");
a.href = att.data; // data URI original
a.download = att.name;
a.click();
Keyboard handler (useEffect)
Escape→ closeArrowLeft/ArrowRight→ navigate (circular: last→first, first→last)+/=/-/0→ zoom controls- Cleanup obligatoriu:
removeEventListenerîn return
React 19 compatibility (FOARTE IMPORTANT)
- NU pune
setStateînuseEffect— React 19 strict mode dă eroare la "Calling setState synchronously within an effect" - NU accesa
ref.currentdirect în render body — tot eroare: "Cannot access refs during render" - Folosește
key={index}pe componentă ca să forțezi remount cu state proaspăt la fiecare deschidere nouă - Zoom/pan reset la navigare: apelează
resetView()direct în callback-ul de navigare, NU într-un effect separat:
const resetView = useCallback(() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}, []);
// În keyboard handler și goNext/goPrev — resetView() inline:
setCurrentIndex((i) => {
const next = i < attachments.length - 1 ? i + 1 : 0;
resetView(); // apelat inline, nu în effect
return next;
});
Styling complet
- Container principal:
fixed inset-0 z-[100] flex flex-col bg-black/90 backdrop-blur-sm animate-in fade-in duration-200 - Top/bottom bars:
bg-black/60 border-b/border-t border-white/10 - Text pe dark background:
text-white,text-white/70,text-white/50 - Buttons:
variant="ghost"cutext-white/70 hover:text-white hover:bg-white/10, sizeh-8 w-8 - Zoom percentage text:
font-mono min-w-[3.5rem] text-center text-xs - Navigation arrows:
rounded-full bg-black/50 hover:bg-black/70 w-10 h-10, centered cuflex items-center justify-center - Imagine:
select-none transition-transform duration-150, stil inline pentru transform - PDF iframe:
w-full h-full border-0
Dependențe
- React:
useState,useEffect,useCallback,useMemo,useRef - lucide-react:
ChevronLeft,ChevronRight,Download,FileText,Minus,Plus,Printer,X - Tailwind CSS (classes direct pe elemente)
- Un
Buttoncomponent din UI library (cu props:variant="ghost",size="icon") cn()utility pentru conditional classes (opțional, poți folosi template literals)
Ce NU trebuie să facă
- NU depinde de Dialog/Radix — e pur
fixed div(simplu, zero probleme cu z-index sau portal nesting) - NU stochează fișiere pe server — totul din
data:URI-urile deja existente în state - NU face streaming/lazy load — fișierul e deja în memorie ca base64
- NU suportă video/audio (doar
image/*șiapplication/pdf) - NU folosește
window.open("file:///...")pentru fișiere locale — browserele blocheazăfile:///URLs din pagini web (security restriction)
Checklist final
- Componenta e un singur fișier exportat
- Funcționează cu imagini (zoom cu scroll wheel, pan cu drag, reset cu 0)
- Funcționează cu PDF-uri (blob URL în iframe, nu data: URI)
- Navigare cu săgeți (keyboard + butoane) când sunt multiple fișiere
- Thumbnail strip în josul ecranului când sunt multiple
- Download și Print funcționează pentru ambele tipuri
- Escape închide preview-ul
- Zero
setStatedirect înuseEffect(React 19 compatible) - Zero
ref.currentaccesat în render body (React 19 compatible) key={index}pe componentă pentru remount cleanURL.revokeObjectURL()apelat la cleanup pentru PDF blob URLse.stopPropagation()pe butoanele de navigare (să nu trigger pan/zoom)