| import { useEffect, useRef, useState } from 'react' |
| import { useCamera } from '../media/useCamera' |
| import { useRecorder } from '../media/useRecorder' |
| import { fileToDataUri, validateAudioFile, validateImageFile } from '../media/validate' |
| import { useI18n } from '../i18n' |
| import { Button } from './ui' |
| import type { MessageKey } from '../i18n/en' |
|
|
| export type Modality = 'face' | 'fingerprint' | 'voice' |
|
|
| function toImagePreview(v: string): string { |
| return v.startsWith('data:') ? v : `data:image/jpeg;base64,${v}` |
| } |
|
|
| interface Props { |
| modality: Modality |
| id: string |
| value: string | null |
| onChange: (dataUri: string | null) => void |
| demoSample?: string |
| } |
|
|
| |
| |
| |
| export function SampleCapture({ modality, id, value, onChange, demoSample }: Props) { |
| const { t } = useI18n() |
| const cam = useCamera() |
| const rec = useRecorder() |
| const [error, setError] = useState<MessageKey | null>(null) |
| const [camOpen, setCamOpen] = useState(false) |
| const fileRef = useRef<HTMLInputElement | null>(null) |
|
|
| |
| useEffect(() => { |
| if (modality === 'voice' && rec.dataUri) { onChange(rec.dataUri); setError(null) } |
| |
| }, [rec.dataUri]) |
|
|
| async function onFile(e: React.ChangeEvent<HTMLInputElement>) { |
| const file = e.target.files?.[0] |
| if (!file) return |
| const err = modality === 'voice' ? validateAudioFile(file) : validateImageFile(file) |
| if (err) { setError(err); return } |
| try { onChange(await fileToDataUri(file)); setError(null) } catch { setError('bio.err.readFailed') } |
| } |
|
|
| function useDemo() { |
| if (demoSample) { onChange(demoSample); setError(null); setCamOpen(false); cam.stop() } |
| } |
|
|
| function openCamera() { setCamOpen(true); setError(null); void cam.start() } |
| function doCapture() { |
| const shot = cam.capture() |
| if (shot) { onChange(shot); setError(null) } |
| setCamOpen(false); cam.stop() |
| } |
| function clear() { onChange(null); rec.reset(); setError(null) } |
|
|
| const label = t(`bio.modality.${modality}` as MessageKey) |
|
|
| return ( |
| <fieldset className="sample" aria-labelledby={`${id}-lbl`}> |
| <legend id={`${id}-lbl`}>{label}</legend> |
| |
| {/* Existing sample preview */} |
| {value && modality !== 'voice' && ( |
| <div className="sample-preview"> |
| <img src={toImagePreview(value)} alt={t('bio.preview', { m: label })} /> |
| <Button onClick={clear}>{t('bio.retake')}</Button> |
| </div> |
| )} |
| {value && modality === 'voice' && ( |
| <div className="sample-preview"> |
| {rec.playbackUrl && <audio src={rec.playbackUrl} controls aria-label={t('bio.playback')} />} |
| <Button onClick={clear}>{t('bio.delete')}</Button> |
| </div> |
| )} |
| |
| {/* Capture controls (hidden once a sample exists) */} |
| {!value && ( |
| <div className="sample-controls"> |
| {modality === 'face' && !camOpen && ( |
| <Button onClick={openCamera}>{t('bio.useCamera')}</Button> |
| )} |
| {modality === 'face' && camOpen && ( |
| <div className="cam"> |
| {cam.status === 'live' && ( |
| <> |
| <video ref={cam.videoRef} playsInline muted aria-label={t('bio.cameraPreview')} /> |
| <div className="row"> |
| <Button onClick={doCapture}>{t('bio.capture')}</Button> |
| <Button onClick={() => { setCamOpen(false); cam.stop() }}>{t('bio.cancel')}</Button> |
| {cam.devices.length > 1 && ( |
| <select aria-label={t('bio.switchCamera')} value={cam.activeDeviceId ?? ''} |
| onChange={(e) => cam.switchTo(e.target.value)}> |
| {cam.devices.map((d, i) => <option key={d.deviceId} value={d.deviceId}>{d.label || `Camera ${i + 1}`}</option>)} |
| </select> |
| )} |
| </div> |
| </> |
| )} |
| {cam.status === 'requesting' && <p className="hint">{t('bio.cameraRequesting')}</p>} |
| {cam.status === 'denied' && <p className="field-error" role="alert">{t('bio.cameraDenied')}</p>} |
| {cam.status === 'nocamera' && <p className="field-error" role="alert">{t('bio.cameraNone')}</p>} |
| {cam.status === 'inuse' && <p className="field-error" role="alert">{t('bio.cameraInUse')}</p>} |
| {cam.status === 'error' && <p className="field-error" role="alert">{t('bio.cameraError')}</p>} |
| </div> |
| )} |
| |
| {modality === 'voice' && ( |
| <div className="rec"> |
| {rec.status !== 'recording' |
| ? <Button onClick={() => void rec.start()}>{t('bio.record')}</Button> |
| : <Button onClick={rec.stop}>{t('bio.stop')} ({rec.elapsed}s)</Button>} |
| {rec.status === 'denied' && <p className="field-error" role="alert">{t('bio.micDenied')}</p>} |
| {rec.status === 'unsupported' && <p className="field-error" role="alert">{t('bio.micUnsupported')}</p>} |
| </div> |
| )} |
| |
| <label className="upload-btn"> |
| {modality === 'voice' ? t('bio.uploadAudio') : t('bio.uploadImage')} |
| <input ref={fileRef} type="file" hidden |
| accept={modality === 'voice' ? 'audio/*' : 'image/*'} onChange={onFile} /> |
| </label> |
| |
| {demoSample && <Button onClick={useDemo}>{t('bio.useDemo')}</Button>} |
| </div> |
| )} |
|
|
| {modality === 'fingerprint' && ( |
| <p className="hint">{t('bio.fingerprintNote')}</p> |
| )} |
| {error && <p className="field-error" role="alert">{t(error)}</p>} |
| </fieldset> |
| ) |
| } |
|
|