236 lines
14 KiB
TypeScript
236 lines
14 KiB
TypeScript
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>;
|
||
}
|