mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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**
|
||||
|
||||
Reference in New Issue
Block a user