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
208 lines
8.3 KiB
Markdown
208 lines
8.3 KiB
Markdown
# 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:
|
||
|
||
```typescript
|
||
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):**
|
||
|
||
```tsx
|
||
// 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>` cu `transform: 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`, cu `e.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-14` cu FileText icon pe fundal `bg-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:
|
||
|
||
```typescript
|
||
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: `onWheel` cu `e.preventDefault()`, `deltaY > 0` = zoom out
|
||
- Keyboard: `+`/`=` zoom in, `-` zoom out, `0` reset la 100%
|
||
- Click pe procentajul afișat resetează la 100%
|
||
|
||
### Pan (doar imagini, doar când zoom > 1)
|
||
|
||
- `onMouseDown`: salvează start position în `useRef` (nu state! — ref e mai performant pentru drag)
|
||
- `onMouseMove`: calculează delta din ref
|
||
- `onMouseUp`/`onMouseLeave`: stop panning
|
||
- Cursor: `cursor-grab` când zoom > 1, `cursor-grabbing` când panning activ
|
||
- `isPanning` e singura stare React; coordonatele start sunt în ref: `useRef({ x: 0, y: 0, panX: 0, panY: 0 })`
|
||
|
||
### Print
|
||
|
||
- **PDF**: creează iframe ascuns cu `src=blobUrl`, `iframe.onload = () => iframe.contentWindow.print()`, cleanup cu `setTimeout(() => document.body.removeChild(iframe), 1000)`
|
||
- **Imagine**: `window.open("", "_blank")` cu HTML inline care conține `<img>`, `w.onload = () => w.print()`
|
||
|
||
### Download
|
||
|
||
```typescript
|
||
const a = document.createElement("a");
|
||
a.href = att.data; // data URI original
|
||
a.download = att.name;
|
||
a.click();
|
||
```
|
||
|
||
### Keyboard handler (useEffect)
|
||
|
||
- `Escape` → close
|
||
- `ArrowLeft`/`ArrowRight` → navigate (circular: last→first, first→last)
|
||
- `+`/`=`/`-`/`0` → zoom controls
|
||
- Cleanup obligatoriu: `removeEventListener` în return
|
||
|
||
### React 19 compatibility (FOARTE IMPORTANT)
|
||
|
||
- **NU pune `setState` în `useEffect`** — React 19 strict mode dă eroare la "Calling setState synchronously within an effect"
|
||
- **NU accesa `ref.current` direct î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:
|
||
|
||
```typescript
|
||
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"` cu `text-white/70 hover:text-white hover:bg-white/10`, size `h-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 cu `flex 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 `Button` component 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/*` și `application/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 `setState` direct în `useEffect` (React 19 compatible)
|
||
- [ ] Zero `ref.current` accesat în render body (React 19 compatible)
|
||
- [ ] `key={index}` pe componentă pentru remount clean
|
||
- [ ] `URL.revokeObjectURL()` apelat la cleanup pentru PDF blob URLs
|
||
- [ ] `e.stopPropagation()` pe butoanele de navigare (să nu trigger pan/zoom)
|