file viewer panel keyed (445 -> 414)

VideoRenderer used t as its track loop variable in two maps, so keying the panel
shadowed the translation function — renamed to track. That is the third panel with
this collision; it is worth knowing that a one-letter loop variable is the main
mechanical hazard in this phase.

The useFileViewer context Error stays English: thrown, not rendered.
This commit is contained in:
2026-08-23 14:49:20 +00:00
parent 44ee444cac
commit fa32c82e0d
12 changed files with 121 additions and 43 deletions
@@ -12,8 +12,10 @@ import { TextRenderer } from './renderers/TextRenderer';
import { ScrollToTopButton } from './renderers/ScrollToTopButton';
import { EditorRenderer } from './renderers/EditorRenderer';
import { JsonEditorRenderer } from './renderers/JsonEditorRenderer';
import { useTranslation } from '../../locales';
export const FileViewerBody = () => {
const { t } = useTranslation();
const { filePath, fileName, root, fileType, content, loading, error, autoPlay, editing, setContent, handleDownload } =
useFileViewer();
const scrollRef = useRef<HTMLDivElement>(null);
@@ -49,7 +51,7 @@ export const FileViewerBody = () => {
className="inline-flex items-center gap-1.5 rounded-md border border-duck-dark/20 px-3 py-1.5 text-xs text-duck-dark transition-colors hover:bg-duck-dark/5 cursor-pointer"
>
<Download className="h-3.5 w-3.5" />
Download instead
{t('fileViewer.downloadInstead')}
</button>
</div>
) : fileType === 'pdf' ? (
@@ -57,7 +59,7 @@ export const FileViewerBody = () => {
) : fileType === 'archive' ? (
<div className="flex flex-col items-center justify-center h-full gap-3 text-duck-dark/50">
<FolderArchive className="h-10 w-10" />
<span className="text-sm">Archive file</span>
<span className="text-sm">{t('fileViewer.archiveFile')}</span>
<span className="text-xs">{getArchiveBaseName(fileName)}</span>
</div>
) : fileType === 'image' ? (
@@ -17,8 +17,10 @@ import {
import { getIcon } from 'material-file-icons';
import { getLang } from './file-types';
import { useFileViewer } from './FileViewerContext';
import { useTranslation } from '../../locales';
export const FileViewerHeader = () => {
const { t } = useTranslation();
const {
fileName,
fileType,
@@ -81,7 +83,7 @@ export const FileViewerHeader = () => {
onClick={handleReadAloud}
disabled={ttsLoading}
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
title="Read Aloud"
title={t('fileViewer.readAloud')}
>
{ttsLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Volume2 className="h-3.5 w-3.5" />}
</button>
@@ -91,7 +93,7 @@ export const FileViewerHeader = () => {
onClick={handleOcr}
disabled={ocrLoading}
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
title="Extract Text (OCR)"
title={t('fileViewer.ocr')}
>
{ocrLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ScanText className="h-3.5 w-3.5" />}
</button>
@@ -101,7 +103,7 @@ export const FileViewerHeader = () => {
onClick={handleTranscribe}
disabled={transcribeLoading}
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
title="Transcribe"
title={t('fileViewer.transcribe')}
>
{transcribeLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
</button>
@@ -111,7 +113,7 @@ export const FileViewerHeader = () => {
onClick={handleExtractAudio}
disabled={extractAudioLoading}
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
title="Extract Audio"
title={t('fileViewer.extractAudio')}
>
{extractAudioLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
@@ -125,7 +127,7 @@ export const FileViewerHeader = () => {
onClick={handleExtract}
disabled={extractLoading}
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
title="Extract"
title={t('fileViewer.extract')}
>
{extractLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
@@ -139,7 +141,7 @@ export const FileViewerHeader = () => {
onClick={handleSaveResult}
disabled={saveResultLoading}
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
title="Save next to original"
title={t('fileViewer.saveNextTo')}
>
{saveResultLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
</button>
@@ -148,7 +150,7 @@ export const FileViewerHeader = () => {
<button
onClick={handleDownload}
className="p-1 rounded hover:bg-current/10 transition-colors cursor-pointer"
title="Download"
title={t('fileViewer.download')}
>
<Download className="h-3.5 w-3.5" />
</button>
@@ -5,6 +5,7 @@ import { useFilesRefresh } from '../../channels';
import { toast } from 'sonner';
import { getFileType } from './file-types';
import { FileViewerContext } from './FileViewerContext';
import { useTranslation } from '../../locales';
type FileViewerProviderProps = {
filePath: string;
@@ -48,6 +49,7 @@ export const FileViewerProvider = ({
autoPlay = false,
children,
}: FileViewerProviderProps) => {
const { t } = useTranslation();
const [content, setContent] = useState<string | null>(directContent ?? null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -104,7 +106,7 @@ export const FileViewerProvider = ({
bumpFilesRefresh();
onReplaceView?.(filePath, root, audioPath, audioRoot);
} catch {
toast.error('Failed to generate speech audio');
toast.error(t('fileViewer.speechFailed'));
} finally {
setTtsLoading(false);
}
@@ -117,7 +119,7 @@ export const FileViewerProvider = ({
bumpFilesRefresh();
onReplaceView?.(filePath, root, ocrPath, ocrRoot);
} catch {
toast.error('Failed to extract text from image');
toast.error(t('fileViewer.ocrFailed'));
} finally {
setOcrLoading(false);
}
@@ -130,7 +132,7 @@ export const FileViewerProvider = ({
bumpFilesRefresh();
onReplaceView?.(filePath, root, transcriptionPath, transcriptionRoot);
} catch {
toast.error('Failed to transcribe audio');
toast.error(t('fileViewer.transcribeFailed'));
} finally {
setTranscribeLoading(false);
}
@@ -142,7 +144,7 @@ export const FileViewerProvider = ({
const { audioPath, audioRoot } = await files.extractAudio(filePath);
onOpenFile?.(audioPath, audioRoot);
} catch {
toast.error('Failed to extract audio from video');
toast.error(t('fileViewer.extractAudioFailed'));
} finally {
setExtractAudioLoading(false);
}
@@ -157,9 +159,9 @@ export const FileViewerProvider = ({
const { savedPath } = await files.saveResult(filePath);
const savedName = savedPath.split('/').pop() ?? savedPath;
bumpFilesRefresh();
toast.success(`Saved as "${savedName}"`);
toast.success(t('fileViewer.savedAs', { name: savedName }));
} catch {
toast.error('Failed to save file');
toast.error(t('fileViewer.saveFailed'));
} finally {
setSaveResultLoading(false);
}
@@ -171,9 +173,9 @@ export const FileViewerProvider = ({
try {
const { extractedPath } = await files.extract(filePath);
const folderName = extractedPath.split('/').pop() ?? extractedPath;
toast.success(`Extracted to "${folderName}"`);
toast.success(t('fileViewer.extractedTo', { name: folderName }));
} catch {
toast.error('Failed to extract archive');
toast.error(t('fileViewer.extractFailed'));
} finally {
setExtractLoading(false);
}
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
import { Loader2, Music, Play, Pause, Volume2, VolumeX } from 'lucide-react';
import { formatTime } from '../file-types';
import { useSeekBar, SeekBar } from './SeekBar';
import { useTranslation } from '../../../locales';
type AudioRendererProps = {
src: string;
@@ -10,6 +11,7 @@ type AudioRendererProps = {
};
export const AudioRenderer = ({ src, fileName, autoPlay = false }: AudioRendererProps) => {
const { t } = useTranslation();
const audioRef = useRef<HTMLAudioElement>(null);
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrent] = useState(0);
@@ -80,7 +82,7 @@ export const AudioRenderer = ({ src, fileName, autoPlay = false }: AudioRenderer
if (error) {
return (
<div className="flex items-center justify-center h-full">
<span className="text-sm text-destructive">Failed to load audio file</span>
<span className="text-sm text-destructive">{t('fileViewer.audioFailed')}</span>
</div>
);
}
@@ -4,6 +4,7 @@ import type { editor as MonacoEditor } from 'monaco-editor';
import { toast } from 'sonner';
import { getLanguage } from '../../CodeEditor/language-map';
import { useFilesAPI } from '../../../hooks/useFilesAPI';
import { useTranslation } from '../../../locales';
type EditorRendererProps = {
content: string;
@@ -14,6 +15,7 @@ type EditorRendererProps = {
};
export const EditorRenderer = ({ content, fileName, filePath, root, onSave }: EditorRendererProps) => {
const { t } = useTranslation();
const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null);
const { writeFile } = useFilesAPI(root);
@@ -24,9 +26,9 @@ export const EditorRenderer = ({ content, fileName, filePath, root, onSave }: Ed
try {
await writeFile(filePath, newContent);
onSave?.(newContent);
toast.success('File saved');
toast.success(t('fileViewer.saved'));
} catch {
toast.error('Failed to save file');
toast.error(t('fileViewer.saveFailed'));
}
}, [filePath, writeFile, onSave]);
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { Loader2, ZoomIn, ZoomOut, RotateCw } from 'lucide-react';
import { useTranslation } from '../../../locales';
const ZOOM_STEPS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 5];
const MIN_ZOOM = ZOOM_STEPS[0]!;
@@ -11,6 +12,7 @@ type ImageRendererProps = {
};
export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const [zoom, setZoom] = useState(1);
const [rotation, setRotation] = useState(0);
@@ -78,7 +80,7 @@ export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
if (error) {
return (
<div className="flex items-center justify-center h-full">
<span className="text-sm text-destructive">Failed to load image</span>
<span className="text-sm text-destructive">{t('fileViewer.imageFailed')}</span>
</div>
);
}
@@ -3,6 +3,7 @@ import { JsonEditor } from '@visual-json/react';
import type { JsonValue } from '@visual-json/core';
import { toast } from 'sonner';
import { useFilesAPI } from '../../../hooks/useFilesAPI';
import { useTranslation } from '../../../locales';
type JsonEditorRendererProps = {
content: string;
@@ -12,6 +13,7 @@ type JsonEditorRendererProps = {
};
export const JsonEditorRenderer = ({ content, filePath, root, onSave }: JsonEditorRendererProps) => {
const { t } = useTranslation();
const { writeFile } = useFilesAPI(root);
const valueRef = useRef<JsonValue>(JSON.parse(content));
@@ -24,9 +26,9 @@ export const JsonEditorRenderer = ({ content, filePath, root, onSave }: JsonEdit
try {
await writeFile(filePath, newContent);
onSave?.(newContent);
toast.success('File saved');
toast.success(t('fileViewer.saved'));
} catch {
toast.error('Failed to save file');
toast.error(t('fileViewer.saveFailed'));
}
}, [filePath, writeFile, onSave]);
@@ -40,12 +42,7 @@ export const JsonEditorRenderer = ({ content, filePath, root, onSave }: JsonEdit
}
}}
>
<JsonEditor
defaultValue={valueRef.current}
onChange={handleChange}
height="100%"
width="100%"
/>
<JsonEditor defaultValue={valueRef.current} onChange={handleChange} height="100%" width="100%" />
</div>
);
};
@@ -1,3 +1,6 @@
export const PdfRenderer = ({ src }: { src: string }) => (
<iframe src={src} className="w-full h-full border-0" title="PDF viewer" />
);
import { useTranslation } from '../../../locales';
export const PdfRenderer = ({ src }: { src: string }) => {
const { t } = useTranslation();
return <iframe src={src} className="w-full h-full border-0" title={t('fileViewer.pdfViewer')} />;
};
@@ -1,11 +1,13 @@
import { useState, useEffect } from 'react';
import { ArrowUp } from 'lucide-react';
import { useTranslation } from '../../../locales';
type ScrollToTopButtonProps = {
scrollContainer: React.RefObject<HTMLDivElement | null>;
};
export const ScrollToTopButton = ({ scrollContainer }: ScrollToTopButtonProps) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
useEffect(() => {
@@ -22,7 +24,7 @@ export const ScrollToTopButton = ({ scrollContainer }: ScrollToTopButtonProps) =
<button
onClick={() => scrollContainer.current?.scrollTo({ top: 0, behavior: 'smooth' })}
className="sticky bottom-4 float-right mr-4 z-10 p-2.5 rounded-full bg-duck-teal text-white shadow-lg hover:bg-duck-teal/90 active:scale-95 transition-all cursor-pointer"
title="Scroll to top"
title={t('fileViewer.scrollTop')}
>
<ArrowUp className="h-4 w-4" />
</button>
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
import { Loader2, Maximize2, Minimize2, Play, Pause, Volume2, VolumeX, Captions, Languages } from 'lucide-react';
import { getExt, formatTime, getRawUrl, getSubtitlesUrl, getSubtitleVttUrl, getAudioTracksUrl } from '../file-types';
import { useSeekBar, SeekBar } from './SeekBar';
import { useTranslation } from '../../../locales';
type VideoRendererProps = {
src: string;
@@ -14,6 +15,7 @@ type SubtitleTrack = { id: number; lang: string; label: string };
type AudioOption = { id: number; label: string };
export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererProps) => {
const { t } = useTranslation();
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const hideTimer = useRef<ReturnType<typeof setTimeout>>(null);
@@ -105,9 +107,14 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
.then((list: Array<{ id: number; lang: string; title: string }>) => {
if (cancelled || !Array.isArray(list)) return;
setSubtitles(
list.map((t) => {
const realLang = t.lang && t.lang !== 'und' && t.lang !== 'unknown' ? t.lang.toUpperCase() : '';
return { id: t.id, lang: t.lang || 'und', label: t.title || realLang || `Track ${t.id + 1}` };
list.map((track) => {
const realLang =
track.lang && track.lang !== 'und' && track.lang !== 'unknown' ? track.lang.toUpperCase() : '';
return {
id: track.id,
lang: track.lang || 'und',
label: track.title || realLang || t('fileViewer.track', { n: track.id + 1 }),
};
}),
);
})
@@ -126,9 +133,10 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
.then((list: Array<{ id: number; lang: string; title: string }>) => {
if (cancelled || !Array.isArray(list)) return;
setAudioTracks(
list.map((t) => {
const realLang = t.lang && t.lang !== 'und' && t.lang !== 'unknown' ? t.lang.toUpperCase() : '';
return { id: t.id, label: t.title || realLang || `Track ${t.id + 1}` };
list.map((track) => {
const realLang =
track.lang && track.lang !== 'und' && track.lang !== 'unknown' ? track.lang.toUpperCase() : '';
return { id: track.id, label: track.title || realLang || t('fileViewer.track', { n: track.id + 1 }) };
}),
);
})
@@ -213,7 +221,7 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
if (error) {
return (
<div className="flex items-center justify-center h-full">
<span className="text-sm text-destructive">Failed to load video</span>
<span className="text-sm text-destructive">{t('fileViewer.videoFailed')}</span>
</div>
);
}
@@ -252,7 +260,7 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
<button
onClick={() => setShowAudioMenu((s) => !s)}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-black/50 backdrop-blur-sm text-xs text-white/90 hover:bg-black/70 transition-colors cursor-pointer"
title="Audio track"
title={t('fileViewer.audioTrack')}
>
<Languages className="h-3.5 w-3.5" />
<span className="max-w-[140px] truncate">{audioTracks[activeAudio]?.label ?? 'Audio'}</span>
@@ -337,8 +345,8 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
<button
onClick={() => setShowSubMenu((s) => !s)}
className={`p-1 transition-colors cursor-pointer ${activeSub !== null ? 'text-duck-teal' : 'text-white/70 hover:text-white'}`}
title="Subtitles"
aria-label="Subtitles"
title={t('fileViewer.subtitles')}
aria-label={t('fileViewer.subtitles')}
>
<Captions className="h-4 w-4" />
</button>
@@ -160,5 +160,33 @@
"grid": "2x2 Grid",
"colsBottom": "Cols + Bottom"
}
},
"fileViewer": {
"readAloud": "Read Aloud",
"ocr": "Extract Text (OCR)",
"transcribe": "Transcribe",
"extractAudio": "Extract Audio",
"extract": "Extract",
"saveNextTo": "Save next to original",
"download": "Download",
"pdfViewer": "PDF viewer",
"videoFailed": "Failed to load video",
"imageFailed": "Failed to load image",
"audioFailed": "Failed to load audio file",
"audioTrack": "Audio track",
"subtitles": "Subtitles",
"track": "Track {{n}}",
"saved": "File saved",
"saveFailed": "Failed to save file",
"scrollTop": "Scroll to top",
"speechFailed": "Failed to generate speech audio",
"ocrFailed": "Failed to extract text from image",
"transcribeFailed": "Failed to transcribe audio",
"extractAudioFailed": "Failed to extract audio from video",
"extractFailed": "Failed to extract archive",
"savedAs": "Saved as “{{name}}”",
"extractedTo": "Extracted to “{{name}}”",
"downloadInstead": "Download instead",
"archiveFile": "Archive file"
}
}
@@ -160,5 +160,33 @@
"grid": "Grelha 2x2",
"colsBottom": "Colunas + inferior"
}
},
"fileViewer": {
"readAloud": "Ler em voz alta",
"ocr": "Extrair texto (OCR)",
"transcribe": "Transcrever",
"extractAudio": "Extrair áudio",
"extract": "Extrair",
"saveNextTo": "Guardar ao lado do original",
"download": "Descarregar",
"pdfViewer": "Visualizador de PDF",
"videoFailed": "Falha ao carregar o vídeo",
"imageFailed": "Falha ao carregar a imagem",
"audioFailed": "Falha ao carregar o ficheiro de áudio",
"audioTrack": "Faixa de áudio",
"subtitles": "Legendas",
"track": "Faixa {{n}}",
"saved": "Ficheiro guardado",
"saveFailed": "Falha ao guardar o ficheiro",
"scrollTop": "Ir para o topo",
"speechFailed": "Falha ao gerar o áudio de fala",
"ocrFailed": "Falha ao extrair texto da imagem",
"transcribeFailed": "Falha ao transcrever o áudio",
"extractAudioFailed": "Falha ao extrair o áudio do vídeo",
"extractFailed": "Falha ao extrair o arquivo",
"savedAs": "Guardado como “{{name}}”",
"extractedTo": "Extraído para “{{name}}”",
"downloadInstead": "Descarregar em vez disso",
"archiveFile": "Ficheiro de arquivo"
}
}