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:
@@ -12,8 +12,10 @@ import { TextRenderer } from './renderers/TextRenderer';
|
|||||||
import { ScrollToTopButton } from './renderers/ScrollToTopButton';
|
import { ScrollToTopButton } from './renderers/ScrollToTopButton';
|
||||||
import { EditorRenderer } from './renderers/EditorRenderer';
|
import { EditorRenderer } from './renderers/EditorRenderer';
|
||||||
import { JsonEditorRenderer } from './renderers/JsonEditorRenderer';
|
import { JsonEditorRenderer } from './renderers/JsonEditorRenderer';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
export const FileViewerBody = () => {
|
export const FileViewerBody = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { filePath, fileName, root, fileType, content, loading, error, autoPlay, editing, setContent, handleDownload } =
|
const { filePath, fileName, root, fileType, content, loading, error, autoPlay, editing, setContent, handleDownload } =
|
||||||
useFileViewer();
|
useFileViewer();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
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"
|
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 className="h-3.5 w-3.5" />
|
||||||
Download instead
|
{t('fileViewer.downloadInstead')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : fileType === 'pdf' ? (
|
) : fileType === 'pdf' ? (
|
||||||
@@ -57,7 +59,7 @@ export const FileViewerBody = () => {
|
|||||||
) : fileType === 'archive' ? (
|
) : fileType === 'archive' ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-duck-dark/50">
|
<div className="flex flex-col items-center justify-center h-full gap-3 text-duck-dark/50">
|
||||||
<FolderArchive className="h-10 w-10" />
|
<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>
|
<span className="text-xs">{getArchiveBaseName(fileName)}</span>
|
||||||
</div>
|
</div>
|
||||||
) : fileType === 'image' ? (
|
) : fileType === 'image' ? (
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import {
|
|||||||
import { getIcon } from 'material-file-icons';
|
import { getIcon } from 'material-file-icons';
|
||||||
import { getLang } from './file-types';
|
import { getLang } from './file-types';
|
||||||
import { useFileViewer } from './FileViewerContext';
|
import { useFileViewer } from './FileViewerContext';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
export const FileViewerHeader = () => {
|
export const FileViewerHeader = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
fileName,
|
fileName,
|
||||||
fileType,
|
fileType,
|
||||||
@@ -81,7 +83,7 @@ export const FileViewerHeader = () => {
|
|||||||
onClick={handleReadAloud}
|
onClick={handleReadAloud}
|
||||||
disabled={ttsLoading}
|
disabled={ttsLoading}
|
||||||
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
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" />}
|
{ttsLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Volume2 className="h-3.5 w-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -91,7 +93,7 @@ export const FileViewerHeader = () => {
|
|||||||
onClick={handleOcr}
|
onClick={handleOcr}
|
||||||
disabled={ocrLoading}
|
disabled={ocrLoading}
|
||||||
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
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" />}
|
{ocrLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ScanText className="h-3.5 w-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -101,7 +103,7 @@ export const FileViewerHeader = () => {
|
|||||||
onClick={handleTranscribe}
|
onClick={handleTranscribe}
|
||||||
disabled={transcribeLoading}
|
disabled={transcribeLoading}
|
||||||
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
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" />}
|
{transcribeLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -111,7 +113,7 @@ export const FileViewerHeader = () => {
|
|||||||
onClick={handleExtractAudio}
|
onClick={handleExtractAudio}
|
||||||
disabled={extractAudioLoading}
|
disabled={extractAudioLoading}
|
||||||
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
||||||
title="Extract Audio"
|
title={t('fileViewer.extractAudio')}
|
||||||
>
|
>
|
||||||
{extractAudioLoading ? (
|
{extractAudioLoading ? (
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
@@ -125,7 +127,7 @@ export const FileViewerHeader = () => {
|
|||||||
onClick={handleExtract}
|
onClick={handleExtract}
|
||||||
disabled={extractLoading}
|
disabled={extractLoading}
|
||||||
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
||||||
title="Extract"
|
title={t('fileViewer.extract')}
|
||||||
>
|
>
|
||||||
{extractLoading ? (
|
{extractLoading ? (
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
@@ -139,7 +141,7 @@ export const FileViewerHeader = () => {
|
|||||||
onClick={handleSaveResult}
|
onClick={handleSaveResult}
|
||||||
disabled={saveResultLoading}
|
disabled={saveResultLoading}
|
||||||
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
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" />}
|
{saveResultLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -148,7 +150,7 @@ export const FileViewerHeader = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
className="p-1 rounded hover:bg-current/10 transition-colors cursor-pointer"
|
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" />
|
<Download className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useFilesRefresh } from '../../channels';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getFileType } from './file-types';
|
import { getFileType } from './file-types';
|
||||||
import { FileViewerContext } from './FileViewerContext';
|
import { FileViewerContext } from './FileViewerContext';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
type FileViewerProviderProps = {
|
type FileViewerProviderProps = {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
@@ -48,6 +49,7 @@ export const FileViewerProvider = ({
|
|||||||
autoPlay = false,
|
autoPlay = false,
|
||||||
children,
|
children,
|
||||||
}: FileViewerProviderProps) => {
|
}: FileViewerProviderProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [content, setContent] = useState<string | null>(directContent ?? null);
|
const [content, setContent] = useState<string | null>(directContent ?? null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -104,7 +106,7 @@ export const FileViewerProvider = ({
|
|||||||
bumpFilesRefresh();
|
bumpFilesRefresh();
|
||||||
onReplaceView?.(filePath, root, audioPath, audioRoot);
|
onReplaceView?.(filePath, root, audioPath, audioRoot);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to generate speech audio');
|
toast.error(t('fileViewer.speechFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setTtsLoading(false);
|
setTtsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -117,7 +119,7 @@ export const FileViewerProvider = ({
|
|||||||
bumpFilesRefresh();
|
bumpFilesRefresh();
|
||||||
onReplaceView?.(filePath, root, ocrPath, ocrRoot);
|
onReplaceView?.(filePath, root, ocrPath, ocrRoot);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to extract text from image');
|
toast.error(t('fileViewer.ocrFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setOcrLoading(false);
|
setOcrLoading(false);
|
||||||
}
|
}
|
||||||
@@ -130,7 +132,7 @@ export const FileViewerProvider = ({
|
|||||||
bumpFilesRefresh();
|
bumpFilesRefresh();
|
||||||
onReplaceView?.(filePath, root, transcriptionPath, transcriptionRoot);
|
onReplaceView?.(filePath, root, transcriptionPath, transcriptionRoot);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to transcribe audio');
|
toast.error(t('fileViewer.transcribeFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setTranscribeLoading(false);
|
setTranscribeLoading(false);
|
||||||
}
|
}
|
||||||
@@ -142,7 +144,7 @@ export const FileViewerProvider = ({
|
|||||||
const { audioPath, audioRoot } = await files.extractAudio(filePath);
|
const { audioPath, audioRoot } = await files.extractAudio(filePath);
|
||||||
onOpenFile?.(audioPath, audioRoot);
|
onOpenFile?.(audioPath, audioRoot);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to extract audio from video');
|
toast.error(t('fileViewer.extractAudioFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setExtractAudioLoading(false);
|
setExtractAudioLoading(false);
|
||||||
}
|
}
|
||||||
@@ -157,9 +159,9 @@ export const FileViewerProvider = ({
|
|||||||
const { savedPath } = await files.saveResult(filePath);
|
const { savedPath } = await files.saveResult(filePath);
|
||||||
const savedName = savedPath.split('/').pop() ?? savedPath;
|
const savedName = savedPath.split('/').pop() ?? savedPath;
|
||||||
bumpFilesRefresh();
|
bumpFilesRefresh();
|
||||||
toast.success(`Saved as "${savedName}"`);
|
toast.success(t('fileViewer.savedAs', { name: savedName }));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to save file');
|
toast.error(t('fileViewer.saveFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaveResultLoading(false);
|
setSaveResultLoading(false);
|
||||||
}
|
}
|
||||||
@@ -171,9 +173,9 @@ export const FileViewerProvider = ({
|
|||||||
try {
|
try {
|
||||||
const { extractedPath } = await files.extract(filePath);
|
const { extractedPath } = await files.extract(filePath);
|
||||||
const folderName = extractedPath.split('/').pop() ?? extractedPath;
|
const folderName = extractedPath.split('/').pop() ?? extractedPath;
|
||||||
toast.success(`Extracted to "${folderName}"`);
|
toast.success(t('fileViewer.extractedTo', { name: folderName }));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to extract archive');
|
toast.error(t('fileViewer.extractFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setExtractLoading(false);
|
setExtractLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
import { Loader2, Music, Play, Pause, Volume2, VolumeX } from 'lucide-react';
|
import { Loader2, Music, Play, Pause, Volume2, VolumeX } from 'lucide-react';
|
||||||
import { formatTime } from '../file-types';
|
import { formatTime } from '../file-types';
|
||||||
import { useSeekBar, SeekBar } from './SeekBar';
|
import { useSeekBar, SeekBar } from './SeekBar';
|
||||||
|
import { useTranslation } from '../../../locales';
|
||||||
|
|
||||||
type AudioRendererProps = {
|
type AudioRendererProps = {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -10,6 +11,7 @@ type AudioRendererProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const AudioRenderer = ({ src, fileName, autoPlay = false }: AudioRendererProps) => {
|
export const AudioRenderer = ({ src, fileName, autoPlay = false }: AudioRendererProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [currentTime, setCurrent] = useState(0);
|
const [currentTime, setCurrent] = useState(0);
|
||||||
@@ -80,7 +82,7 @@ export const AudioRenderer = ({ src, fileName, autoPlay = false }: AudioRenderer
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { editor as MonacoEditor } from 'monaco-editor';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getLanguage } from '../../CodeEditor/language-map';
|
import { getLanguage } from '../../CodeEditor/language-map';
|
||||||
import { useFilesAPI } from '../../../hooks/useFilesAPI';
|
import { useFilesAPI } from '../../../hooks/useFilesAPI';
|
||||||
|
import { useTranslation } from '../../../locales';
|
||||||
|
|
||||||
type EditorRendererProps = {
|
type EditorRendererProps = {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -14,6 +15,7 @@ type EditorRendererProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const EditorRenderer = ({ content, fileName, filePath, root, onSave }: EditorRendererProps) => {
|
export const EditorRenderer = ({ content, fileName, filePath, root, onSave }: EditorRendererProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null);
|
const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null);
|
||||||
const { writeFile } = useFilesAPI(root);
|
const { writeFile } = useFilesAPI(root);
|
||||||
|
|
||||||
@@ -24,9 +26,9 @@ export const EditorRenderer = ({ content, fileName, filePath, root, onSave }: Ed
|
|||||||
try {
|
try {
|
||||||
await writeFile(filePath, newContent);
|
await writeFile(filePath, newContent);
|
||||||
onSave?.(newContent);
|
onSave?.(newContent);
|
||||||
toast.success('File saved');
|
toast.success(t('fileViewer.saved'));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to save file');
|
toast.error(t('fileViewer.saveFailed'));
|
||||||
}
|
}
|
||||||
}, [filePath, writeFile, onSave]);
|
}, [filePath, writeFile, onSave]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Loader2, ZoomIn, ZoomOut, RotateCw } from 'lucide-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 ZOOM_STEPS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 5];
|
||||||
const MIN_ZOOM = ZOOM_STEPS[0]!;
|
const MIN_ZOOM = ZOOM_STEPS[0]!;
|
||||||
@@ -11,6 +12,7 @@ type ImageRendererProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
|
export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
const [rotation, setRotation] = useState(0);
|
const [rotation, setRotation] = useState(0);
|
||||||
@@ -78,7 +80,7 @@ export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { JsonEditor } from '@visual-json/react';
|
|||||||
import type { JsonValue } from '@visual-json/core';
|
import type { JsonValue } from '@visual-json/core';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useFilesAPI } from '../../../hooks/useFilesAPI';
|
import { useFilesAPI } from '../../../hooks/useFilesAPI';
|
||||||
|
import { useTranslation } from '../../../locales';
|
||||||
|
|
||||||
type JsonEditorRendererProps = {
|
type JsonEditorRendererProps = {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -12,6 +13,7 @@ type JsonEditorRendererProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const JsonEditorRenderer = ({ content, filePath, root, onSave }: JsonEditorRendererProps) => {
|
export const JsonEditorRenderer = ({ content, filePath, root, onSave }: JsonEditorRendererProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { writeFile } = useFilesAPI(root);
|
const { writeFile } = useFilesAPI(root);
|
||||||
const valueRef = useRef<JsonValue>(JSON.parse(content));
|
const valueRef = useRef<JsonValue>(JSON.parse(content));
|
||||||
|
|
||||||
@@ -24,9 +26,9 @@ export const JsonEditorRenderer = ({ content, filePath, root, onSave }: JsonEdit
|
|||||||
try {
|
try {
|
||||||
await writeFile(filePath, newContent);
|
await writeFile(filePath, newContent);
|
||||||
onSave?.(newContent);
|
onSave?.(newContent);
|
||||||
toast.success('File saved');
|
toast.success(t('fileViewer.saved'));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to save file');
|
toast.error(t('fileViewer.saveFailed'));
|
||||||
}
|
}
|
||||||
}, [filePath, writeFile, onSave]);
|
}, [filePath, writeFile, onSave]);
|
||||||
|
|
||||||
@@ -40,12 +42,7 @@ export const JsonEditorRenderer = ({ content, filePath, root, onSave }: JsonEdit
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<JsonEditor
|
<JsonEditor defaultValue={valueRef.current} onChange={handleChange} height="100%" width="100%" />
|
||||||
defaultValue={valueRef.current}
|
|
||||||
onChange={handleChange}
|
|
||||||
height="100%"
|
|
||||||
width="100%"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
export const PdfRenderer = ({ src }: { src: string }) => (
|
import { useTranslation } from '../../../locales';
|
||||||
<iframe src={src} className="w-full h-full border-0" title="PDF viewer" />
|
|
||||||
);
|
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 { useState, useEffect } from 'react';
|
||||||
import { ArrowUp } from 'lucide-react';
|
import { ArrowUp } from 'lucide-react';
|
||||||
|
import { useTranslation } from '../../../locales';
|
||||||
|
|
||||||
type ScrollToTopButtonProps = {
|
type ScrollToTopButtonProps = {
|
||||||
scrollContainer: React.RefObject<HTMLDivElement | null>;
|
scrollContainer: React.RefObject<HTMLDivElement | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ScrollToTopButton = ({ scrollContainer }: ScrollToTopButtonProps) => {
|
export const ScrollToTopButton = ({ scrollContainer }: ScrollToTopButtonProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -22,7 +24,7 @@ export const ScrollToTopButton = ({ scrollContainer }: ScrollToTopButtonProps) =
|
|||||||
<button
|
<button
|
||||||
onClick={() => scrollContainer.current?.scrollTo({ top: 0, behavior: 'smooth' })}
|
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"
|
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" />
|
<ArrowUp className="h-4 w-4" />
|
||||||
</button>
|
</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 { Loader2, Maximize2, Minimize2, Play, Pause, Volume2, VolumeX, Captions, Languages } from 'lucide-react';
|
||||||
import { getExt, formatTime, getRawUrl, getSubtitlesUrl, getSubtitleVttUrl, getAudioTracksUrl } from '../file-types';
|
import { getExt, formatTime, getRawUrl, getSubtitlesUrl, getSubtitleVttUrl, getAudioTracksUrl } from '../file-types';
|
||||||
import { useSeekBar, SeekBar } from './SeekBar';
|
import { useSeekBar, SeekBar } from './SeekBar';
|
||||||
|
import { useTranslation } from '../../../locales';
|
||||||
|
|
||||||
type VideoRendererProps = {
|
type VideoRendererProps = {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -14,6 +15,7 @@ type SubtitleTrack = { id: number; lang: string; label: string };
|
|||||||
type AudioOption = { id: number; label: string };
|
type AudioOption = { id: number; label: string };
|
||||||
|
|
||||||
export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererProps) => {
|
export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const hideTimer = useRef<ReturnType<typeof setTimeout>>(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 }>) => {
|
.then((list: Array<{ id: number; lang: string; title: string }>) => {
|
||||||
if (cancelled || !Array.isArray(list)) return;
|
if (cancelled || !Array.isArray(list)) return;
|
||||||
setSubtitles(
|
setSubtitles(
|
||||||
list.map((t) => {
|
list.map((track) => {
|
||||||
const realLang = t.lang && t.lang !== 'und' && t.lang !== 'unknown' ? t.lang.toUpperCase() : '';
|
const realLang =
|
||||||
return { id: t.id, lang: t.lang || 'und', label: t.title || realLang || `Track ${t.id + 1}` };
|
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 }>) => {
|
.then((list: Array<{ id: number; lang: string; title: string }>) => {
|
||||||
if (cancelled || !Array.isArray(list)) return;
|
if (cancelled || !Array.isArray(list)) return;
|
||||||
setAudioTracks(
|
setAudioTracks(
|
||||||
list.map((t) => {
|
list.map((track) => {
|
||||||
const realLang = t.lang && t.lang !== 'und' && t.lang !== 'unknown' ? t.lang.toUpperCase() : '';
|
const realLang =
|
||||||
return { id: t.id, label: t.title || realLang || `Track ${t.id + 1}` };
|
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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -252,7 +260,7 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowAudioMenu((s) => !s)}
|
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"
|
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" />
|
<Languages className="h-3.5 w-3.5" />
|
||||||
<span className="max-w-[140px] truncate">{audioTracks[activeAudio]?.label ?? 'Audio'}</span>
|
<span className="max-w-[140px] truncate">{audioTracks[activeAudio]?.label ?? 'Audio'}</span>
|
||||||
@@ -337,8 +345,8 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowSubMenu((s) => !s)}
|
onClick={() => setShowSubMenu((s) => !s)}
|
||||||
className={`p-1 transition-colors cursor-pointer ${activeSub !== null ? 'text-duck-teal' : 'text-white/70 hover:text-white'}`}
|
className={`p-1 transition-colors cursor-pointer ${activeSub !== null ? 'text-duck-teal' : 'text-white/70 hover:text-white'}`}
|
||||||
title="Subtitles"
|
title={t('fileViewer.subtitles')}
|
||||||
aria-label="Subtitles"
|
aria-label={t('fileViewer.subtitles')}
|
||||||
>
|
>
|
||||||
<Captions className="h-4 w-4" />
|
<Captions className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -160,5 +160,33 @@
|
|||||||
"grid": "2x2 Grid",
|
"grid": "2x2 Grid",
|
||||||
"colsBottom": "Cols + Bottom"
|
"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",
|
"grid": "Grelha 2x2",
|
||||||
"colsBottom": "Colunas + inferior"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user