Files
dh-inspeccion-v2/web-v2/src/features/assets/AssetMediaPanel.tsx
T

236 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useRef, useState } from 'react';
import type { FormEvent } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
getAssetMediaBlob,
listAssetMedia,
removeAssetMedia,
updateAssetMedia,
uploadAssetMedia,
} from '../../lib/api';
import type { AssetMedia, AssetMediaKind } from '../../lib/api';
import { formatDate } from '../../lib/format';
function localDateTime(value: string | number | Date): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function fileSize(value: number) {
return value >= 1024 * 1024
? `${(value / (1024 * 1024)).toFixed(1)} MB`
: `${Math.max(1, Math.round(value / 1024))} KB`;
}
function AssetPhotoPreview({ media }: { media: AssetMedia }) {
const [url, setUrl] = useState('');
useEffect(() => {
let active = true;
let objectUrl = '';
getAssetMediaBlob(media.id)
.then((blob) => {
if (!active) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
})
.catch(() => undefined);
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [media.id]);
return url
? <img src={url} alt={media.title || media.originalName} />
: <div className="media-preview-loading"><span className="spinner" /></div>;
}
export function AssetMediaPanel({
assetId,
assetName,
canManage,
onChanged = () => undefined,
}: {
assetId: string;
assetName: string;
canManage: boolean;
onChanged?: () => void;
}) {
const fileRef = useRef<HTMLInputElement | null>(null);
const [items, setItems] = useState<AssetMedia[]>([]);
const [kind, setKind] = useState<AssetMediaKind>('PHOTO');
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [capturedAt, setCapturedAt] = useState('');
const [latitude, setLatitude] = useState('');
const [longitude, setLongitude] = useState('');
const [accuracyM, setAccuracyM] = useState('');
const [editing, setEditing] = useState<AssetMedia | null>(null);
const [editTitle, setEditTitle] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editCapturedAt, setEditCapturedAt] = useState('');
const [editLatitude, setEditLatitude] = useState('');
const [editLongitude, setEditLongitude] = useState('');
const [editAccuracyM, setEditAccuracyM] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [locating, setLocating] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const load = () => listAssetMedia(assetId).then(setItems);
useEffect(() => {
setLoading(true);
load()
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [assetId]);
const resetUpload = () => {
setFile(null); setTitle(''); setDescription(''); setCapturedAt('');
setLatitude(''); setLongitude(''); setAccuracyM('');
if (fileRef.current) fileRef.current.value = '';
};
const useDeviceLocation = () => {
if (!navigator.geolocation) {
setError('Este navegador no permite obtener la ubicación del dispositivo.');
return;
}
setLocating(true); setError('');
navigator.geolocation.getCurrentPosition(
(position) => {
setLatitude(position.coords.latitude.toFixed(6));
setLongitude(position.coords.longitude.toFixed(6));
setAccuracyM(position.coords.accuracy.toFixed(3));
if (!capturedAt) setCapturedAt(localDateTime(position.timestamp));
setLocating(false);
},
() => {
setError('No fue posible obtener la ubicación del dispositivo.');
setLocating(false);
},
{ enableHighAccuracy: true, timeout: 15_000, maximumAge: 0 },
);
};
const upload = async (event: FormEvent) => {
event.preventDefault();
if (!file) return;
setSaving(true); setError(''); setSuccess('');
try {
await uploadAssetMedia(assetId, {
file,
kind,
title: title.trim() || undefined,
description: description.trim() || undefined,
capturedAt: capturedAt ? new Date(capturedAt).toISOString() : undefined,
latitude: latitude ? Number(latitude) : undefined,
longitude: longitude ? Number(longitude) : undefined,
accuracyM: accuracyM ? Number(accuracyM) : undefined,
});
await load();
resetUpload();
setSuccess('Archivo incorporado correctamente');
onChanged();
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const openEdit = (media: AssetMedia) => {
setEditing(media);
setEditTitle(media.title ?? '');
setEditDescription(media.description ?? '');
setEditCapturedAt(media.capturedAt ? localDateTime(media.capturedAt) : '');
setEditLatitude(media.latitude == null ? '' : String(media.latitude));
setEditLongitude(media.longitude == null ? '' : String(media.longitude));
setEditAccuracyM(media.accuracyM == null ? '' : String(media.accuracyM));
};
const saveEdit = async (event: FormEvent) => {
event.preventDefault();
if (!editing) return;
setSaving(true); setError(''); setSuccess('');
try {
await updateAssetMedia(editing.id, {
title: editTitle.trim() || null,
description: editDescription.trim() || null,
capturedAt: editCapturedAt ? new Date(editCapturedAt).toISOString() : null,
latitude: editLatitude ? Number(editLatitude) : null,
longitude: editLongitude ? Number(editLongitude) : null,
accuracyM: editAccuracyM ? Number(editAccuracyM) : null,
});
await load();
setEditing(null);
setSuccess('Metadatos actualizados correctamente');
onChanged();
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const remove = async (media: AssetMedia) => {
if (!window.confirm(`¿Retirar “${media.title || media.originalName}” de ${assetName}? El original se conservará en el almacenamiento protegido.`)) return;
setSaving(true); setError(''); setSuccess('');
try {
await removeAssetMedia(media.id);
await load();
setSuccess('Archivo retirado; el original quedó preservado');
onChanged();
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const download = async (media: AssetMedia) => {
setError('');
try {
const blob = await getAssetMediaBlob(media.id, true);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = media.originalName;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (requestError) {
setError(errorMessage(requestError));
}
};
const photos = items.filter((item) => item.kind === 'PHOTO');
const documents = items.filter((item) => item.kind === 'DOCUMENT');
return <article className="panel asset-media-panel">
<div className="panel-heading"><div><span className="eyebrow">ARCHIVOS DEL INVENTARIO</span><h2>Fotografías y documentos</h2></div><span className="count-pill">{items.length} archivo{items.length === 1 ? '' : 's'}</span></div>
<p className="section-copy">Originales protegidos con hash SHA-256, autor, fecha y ubicación opcional. Formatos permitidos: JPG, PNG, WebP y PDF de hasta 15 MB.</p>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{canManage && <form className="media-upload-form" onSubmit={upload}>
<div className="form-grid"><label className="field"><span>Clase de archivo</span><SearchableSelect value={kind} onChange={(event) => { setKind(event.target.value as AssetMediaKind); setFile(null); if (fileRef.current) fileRef.current.value = ''; }}><option value="PHOTO">Fotografía</option><option value="DOCUMENT">Documento PDF</option></SearchableSelect></label><label className="field"><span>Archivo</span><input ref={fileRef} type="file" accept={kind === 'PHOTO' ? 'image/jpeg,image/png,image/webp' : 'application/pdf'} capture={kind === 'PHOTO' ? 'environment' : undefined} onChange={(event) => setFile(event.target.files?.[0] ?? null)} required /></label></div>
<div className="form-grid"><label className="field"><span>Título <em>opcional</em></span><input value={title} onChange={(event) => setTitle(event.target.value)} maxLength={200} /></label><label className="field"><span>Fecha de captura <em>opcional</em></span><input type="datetime-local" value={capturedAt} onChange={(event) => setCapturedAt(event.target.value)} /></label></div>
<label className="field"><span>Descripción <em>opcional</em></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={2} maxLength={4000} /></label>
<div className="media-location-row"><button type="button" className="button secondary" onClick={useDeviceLocation} disabled={locating}><Icon name="map" />{locating ? 'Obteniendo GPS…' : 'Agregar ubicación actual'}</button>{latitude && longitude ? <span>{latitude}, {longitude}{accuracyM ? ` · ±${accuracyM} m` : ''}</span> : <span>Sin coordenadas</span>}<button type="button" className="button text" onClick={() => { setLatitude(''); setLongitude(''); setAccuracyM(''); }}>Limpiar GPS</button></div>
<div className="form-actions"><button className="button primary" disabled={!file || saving}><Icon name="plus" />{saving ? 'Subiendo…' : 'Incorporar archivo'}</button></div>
</form>}
{loading ? <LoadingBlock label="Cargando archivos…" /> : items.length === 0 ? <div className="inline-empty">Este registro todavía no tiene fotografías ni documentos.</div> : <>
{photos.length > 0 && <section className="media-section"><h3>Fotografías</h3><div className="media-photo-grid">{photos.map((media) => <article className="media-card photo" key={media.id}><div className="media-photo-preview"><AssetPhotoPreview media={media} /></div><div className="media-card-copy"><strong>{media.title || media.originalName}</strong><small>{fileSize(media.sizeBytes)} · {formatDate(media.capturedAt || media.createdAt)}</small>{media.description && <p>{media.description}</p>}<div className="media-card-actions"><button type="button" className="button text" onClick={() => download(media)}>Descargar</button>{canManage && <button type="button" className="button text" onClick={() => openEdit(media)}>Editar</button>}{canManage && <button type="button" className="button text danger-text" onClick={() => remove(media)} disabled={saving}>Retirar</button>}</div></div></article>)}</div></section>}
{documents.length > 0 && <section className="media-section"><h3>Documentos</h3><div className="media-document-list">{documents.map((media) => <article className="media-card document" key={media.id}><span className="asset-symbol"><Icon name="clipboard" /></span><div className="media-card-copy"><strong>{media.title || media.originalName}</strong><small>{media.originalName} · {fileSize(media.sizeBytes)} · {formatDate(media.createdAt)}</small>{media.description && <p>{media.description}</p>}</div><div className="media-card-actions"><button type="button" className="button secondary" onClick={() => download(media)}>Descargar</button>{canManage && <button type="button" className="button text" onClick={() => openEdit(media)}>Editar</button>}{canManage && <button type="button" className="button text danger-text" onClick={() => remove(media)} disabled={saving}>Retirar</button>}</div></article>)}</div></section>}
</>}
{editing && <div className="modal-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget && !saving) setEditing(null); }}><aside className="detail-drawer media-edit-drawer" role="dialog" aria-modal="true" aria-label="Editar metadatos"><form onSubmit={saveEdit}><div className="drawer-heading"><div><span className="eyebrow">METADATOS DEL ARCHIVO</span><h2>{editing.title || editing.originalName}</h2></div><button type="button" className="icon-button" onClick={() => setEditing(null)} aria-label="Cerrar">×</button></div><div className="media-edit-fields"><label className="field"><span>Título</span><input value={editTitle} onChange={(event) => setEditTitle(event.target.value)} maxLength={200} /></label><label className="field"><span>Descripción</span><textarea value={editDescription} onChange={(event) => setEditDescription(event.target.value)} rows={4} maxLength={4000} /></label><label className="field"><span>Fecha de captura</span><input type="datetime-local" value={editCapturedAt} onChange={(event) => setEditCapturedAt(event.target.value)} /></label><div className="form-grid"><label className="field"><span>Latitud</span><input type="number" min="-90" max="90" step="0.000001" value={editLatitude} onChange={(event) => setEditLatitude(event.target.value)} /></label><label className="field"><span>Longitud</span><input type="number" min="-180" max="180" step="0.000001" value={editLongitude} onChange={(event) => setEditLongitude(event.target.value)} /></label></div><label className="field"><span>Precisión GPS en metros</span><input type="number" min="0" max="100000" step="0.001" value={editAccuracyM} onChange={(event) => setEditAccuracyM(event.target.value)} /></label><div className="form-actions"><button type="button" className="button secondary" onClick={() => setEditing(null)}>Cancelar</button><button className="button primary" disabled={saving}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar metadatos'}</button></div></div></form></aside></div>}
</article>;
}