Files
controller-v2/client/src/components/FirRoomDetails.tsx
T
2026-10-30 17:40:13 +08:00

68 lines
4.1 KiB
TypeScript

import { useState } from 'react';
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogClose } from '@/components/ui/dialog';
import { firImageUrl, firRoomName, type FirRoom } from '@/lib/fir';
import { firTranslate } from '@/lib/firLocale';
const LIBRARY = (import.meta.env.VITE_FIR_API_BASE || '/fir-library').replace(/\/$/, '');
type Props = { room: FirRoom; language?: number };
export function FirRoomDetailsBody({ room, language }: Props) {
const t = (key: string) => firTranslate(language, key);
const imageUrl = firImageUrl(room.image_url, LIBRARY);
const [failedImage, setFailedImage] = useState<string | null>(null);
const metadata = room.metadata || {};
const value = (key: string) => metadata[key]?.trim() || '—';
// Only attach units to scalar values; preserve CSV configuration annotations verbatim.
const unit = (text: string, suffix: string) => /^[+-]?\d+(?:\.\d+)?$/.test(text) ? `${text} ${suffix}` : text;
const groups: { title: string; fields: [string, string][] }[] = [
{ title: 'detailRoom', fields: [
['detailName', firRoomName(room) || t('unnamed')], ['detailId', room.room_id],
['detailLocation', value('Room Location')], ['detailType', value('Room Type')],
['detailDimensions', value('Room Dimensions (m) (l x w x h)')],
] },
{ title: 'detailAcoustics', fields: [
['detailRt60', unit(value('RT60 (s)'), 's')],
['detailReduced', /^(yes|no)$/i.test(value('Reduced Reverberation?')) ? t(value('Reduced Reverberation?').toLowerCase() === 'yes' ? 'detailYes' : 'detailNo') : value('Reduced Reverberation?')],
['detailListener', value('Listener')], ['detailDistance', unit(value('Source Distance (m)'), 'm')],
['detailAzimuthRange', value('Source Azimuth Range')], ['detailElevationRange', value('Source Elevation Range')],
['detailConfigurations', value('Measurement Configurations')],
] },
];
const renderGroup = (group: typeof groups[number]) => <section key={group.title}>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-cyan-300">{t(group.title)}</h3>
<dl className="grid grid-cols-2 gap-x-6 gap-y-3">
{group.fields.map(([label, text]) => <div key={label} className="min-w-0 border-b border-white/10 pb-2">
<dt className="mb-1 text-xs text-white/45">{t(label)}</dt>
<dd className="break-words text-sm leading-relaxed text-white/90">{text}</dd>
</div>)}
</dl>
</section>;
const hasImage = imageUrl && failedImage !== imageUrl;
return <div className="space-y-5">
{hasImage && <figure className="space-y-2">
<a href={imageUrl} target="_blank" rel="noopener noreferrer" className="block overflow-hidden rounded-xl border border-white/15 bg-[#f8fafc]" aria-label={t('detailEnlarge')}>
<img src={imageUrl} alt={firRoomName(room)} className="block h-auto w-full" onError={() => setFailedImage(imageUrl)} />
</a>
<figcaption className="flex items-center justify-between gap-3 text-xs text-white/50">
<span>{t('detailDiagram')}</span>
<a href={imageUrl} target="_blank" rel="noopener noreferrer" className="shrink-0 text-cyan-300 hover:underline">{t('detailEnlarge')} ↗</a>
</figcaption>
</figure>}
{!hasImage && groups.map(renderGroup)}
</div>;
}
export default function FirRoomDetails({ room, language, open, onOpenChange }: Props & { open: boolean; onOpenChange: (open: boolean) => void }) {
const t = (key: string) => firTranslate(language, key);
return <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton={false} className="max-h-[85dvh] overflow-y-auto border-white/15 bg-[#19191c] text-white sm:max-w-4xl">
<div className="flex items-start justify-between gap-4">
<div><DialogTitle className="text-lg">{firRoomName(room) || t('unnamed')}</DialogTitle>
<DialogDescription className="mt-1 text-xs text-white/50">{t('detailTitle')}</DialogDescription></div>
<DialogClose className="shrink-0 rounded-md px-2 py-1 text-sm text-cyan-300 hover:bg-white/10">{t('detailClose')}</DialogClose>
</div>
<FirRoomDetailsBody room={room} language={language} />
</DialogContent>
</Dialog>;
}