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:
@@ -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)
|
||||
Reference in New Issue
Block a user