feat(parcel-sync): full GPKG export workflow with UAT autocomplete, hero buttons, layer catalog

- 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
This commit is contained in:
AI Assistant
2026-03-06 06:53:49 +02:00
parent 7cdea66fa2
commit 09a24233bb
10 changed files with 15102 additions and 566 deletions
+207
View File
@@ -0,0 +1,207 @@
# 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)