feat(r3.3): aperçu réel des documents + ouverture dans le navigateur

Retour de pré-recette : les fichiers téléversés n'avaient ni aperçu ni
ouverture. Les images affichent leur vraie vignette (blob authentifié →
URL objet révoquée au démontage — un <img src> nu ne porte pas le jeton) ;
« Ouvrir » (bouton + clic vignette) affiche PDF et images dans un nouvel
onglet, ouvert dans le geste utilisateur pour passer les bloqueurs de
pop-up. « Télécharger » inchangé.

Vérifié en navigateur réel : vignette chargée, onglets blob (PDF rendu
dans la visionneuse), download intact, zéro erreur console.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-16 22:44:22 +01:00
parent c5382a5538
commit c46a97ed08
4 changed files with 94 additions and 6 deletions

View File

@@ -217,13 +217,17 @@ export function useDeleteDocument() {
});
}
/** Téléchargement authentifié (un lien nu ne porte pas le jeton). */
export async function telechargerDocument(id: string, fileName: string): Promise<void> {
/** Récupération authentifiée du fichier (un lien nu ne porte pas le jeton). */
export async function blobDocument(id: string): Promise<Blob> {
const res = await fetch(`/api/documents/${id}/download`, {
headers: { Authorization: `Bearer ${getToken()}` },
});
if (!res.ok) throw new Error('Téléchargement impossible');
const blob = await res.blob();
return res.blob();
}
export async function telechargerDocument(id: string, fileName: string): Promise<void> {
const blob = await blobDocument(id);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -232,6 +236,22 @@ export async function telechargerDocument(id: string, fileName: string): Promise
URL.revokeObjectURL(url);
}
/** Ouverture dans un nouvel onglet (PDF et images : le navigateur les affiche). */
export async function ouvrirDocument(id: string): Promise<void> {
// Onglet ouvert dans le geste utilisateur, sinon les bloqueurs de pop-up
// refusent un window.open survenant après le fetch.
const onglet = window.open('', '_blank');
try {
const blob = await blobDocument(id);
const url = URL.createObjectURL(blob);
if (onglet) onglet.location.href = url;
else window.open(url, '_blank');
} catch (e) {
onglet?.close();
throw e;
}
}
// ————— Analytics —————
export function useAnalyticsSummary(enabled = true) {

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import {
DOCUMENT_KIND_LABELS,
DOCUMENT_KINDS,
@@ -6,7 +6,14 @@ import {
type DocumentDto,
type DocumentKind,
} from '@siop/shared';
import { telechargerDocument, useDeleteDocument, useDocuments, useUploadDocument } from '@/api/gestion';
import {
blobDocument,
ouvrirDocument,
telechargerDocument,
useDeleteDocument,
useDocuments,
useUploadDocument,
} from '@/api/gestion';
import { Button } from '@/components/ui/button';
const CLASSE_TYPE: Partial<Record<DocumentKind, string>> = {
@@ -20,10 +27,46 @@ export function tailleLisible(octets: number): string {
return `${Math.max(1, Math.round(octets / 1024))} Ko`;
}
/** Aperçu réel des images : le fichier exige le jeton, donc pas de <img src>
* direct — on récupère le blob et on affiche une URL objet, révoquée au
* démontage. Les PDF gardent leur pictogramme (le navigateur les ouvre). */
function ApercuVignette({ doc }: { doc: DocumentDto }) {
const estImage = doc.contentType.startsWith('image/');
const [url, setUrl] = useState<string>();
useEffect(() => {
if (!estImage) return;
let actif = true;
let objet: string | undefined;
blobDocument(doc.id)
.then((blob) => {
if (!actif) return;
objet = URL.createObjectURL(blob);
setUrl(objet);
})
.catch(() => {});
return () => {
actif = false;
if (objet) URL.revokeObjectURL(objet);
};
}, [doc.id, estImage]);
return (
<button
type="button"
className="vignette"
title={`Ouvrir ${doc.fileName}`}
onClick={() => void ouvrirDocument(doc.id)}
>
{url ? <img src={url} alt={doc.fileName} /> : estImage ? '🖼' : '📄'}
</button>
);
}
export function VignetteDoc({ doc, surSuppression }: { doc: DocumentDto; surSuppression?: (id: string) => void }) {
return (
<div className="doc">
<div className="vignette">{doc.contentType.startsWith('image/') ? '🖼' : '📄'}</div>
<ApercuVignette doc={doc} />
<span className={CLASSE_TYPE[doc.kind] ?? 'type-doc'}>{DOCUMENT_KIND_LABELS[doc.kind]}</span>
<b>{doc.fileName}</b>
<span className="meta">
@@ -34,6 +77,13 @@ export function VignetteDoc({ doc, surSuppression }: { doc: DocumentDto; surSupp
{doc.uploadedByName ? ` · ${doc.uploadedByName}` : ''}
</span>
<span style={{ display: 'flex', gap: 8 }}>
<button
type="button"
onClick={() => void ouvrirDocument(doc.id)}
style={{ color: 'var(--primaire)', fontWeight: 600, fontSize: 12.5 }}
>
Ouvrir
</button>
<button
type="button"
onClick={() => void telechargerDocument(doc.id, doc.fileName)}

View File

@@ -906,7 +906,9 @@ table {
height: 84px; border-radius: 8px; background: var(--surface-2);
display: flex; align-items: center; justify-content: center;
color: var(--encre-3); font-size: 24px;
width: 100%; padding: 0; border: 0; overflow: hidden; cursor: pointer;
}
.doc .vignette img { width: 100%; height: 100%; object-fit: cover; }
.doc b { font-size: 13px; word-break: break-all; }
.doc .meta { color: var(--encre-2); font-size: 11.5px; }
.type-doc {

View File

@@ -4,6 +4,22 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
---
## 2026-07-16 — Pr. Daaif (+ Claude) — R3.3+ : aperçu et ouverture des documents (retour de pré-recette)
**Actions**
- **Retour du référent** : les documents téléversés n'avaient ni aperçu ni ouverture (seul « Télécharger » existait). Corrigé dans la vignette (`carte-documents.tsx`) : les **images affichent leur vrai aperçu** (le fichier exige le jeton → récupération du blob puis URL objet révoquée au démontage, jamais de `<img src>` nu) ; les PDF gardent leur pictogramme.
- **« Ouvrir »** : nouveau bouton (et clic sur la vignette) — onglet ouvert **dans le geste utilisateur** (sinon bloqueurs de pop-up) puis pointé sur le blob ; le navigateur affiche PDF et images dans sa visionneuse. « Télécharger » inchangé.
- Vérifié en navigateur réel (Playwright, appli lancée) : vignette image réellement chargée (`naturalWidth > 0`), ouverture image et PDF en onglet `blob:` (PDF rendu dans la visionneuse Chrome — capture), téléchargement intact, suppression OK, zéro erreur console. Typecheck + vitest verts.
**Décisions**
- L'aperçu télécharge le fichier entier (pas de miniature côté serveur) : acceptable au volume actuel ; si la bibliothèque grossit, générer des miniatures à l'upload (noté pour le durcissement).
**Prochaine étape** : recette R3 avec le référent (revue pixel), déploiement Dokploy, tag `release/r3` → puis R4 Mobile (design d'abord).
---
## 2026-07-16 — Pr. Daaif (+ Claude) — R3.3 : les écrans web de la gestion — R3 prête pour recette
**Actions**