diff --git a/components/app-header/load-srt.tsx b/components/app-header/load-srt.tsx index ee50fe8..411ff31 100644 --- a/components/app-header/load-srt.tsx +++ b/components/app-header/load-srt.tsx @@ -12,6 +12,7 @@ import { AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import TranscriptImportDialog from "@/components/transcript-import-dialog"; import { Dialog, DialogClose, @@ -39,6 +40,7 @@ import { getReadableTextColor, getTrackHandleColor } from "@/lib/track-colors"; import { IconBadgeCc, IconFile, + IconFileText, IconPencilPlus, IconPlus, IconTrash, @@ -251,6 +253,16 @@ export default function LoadSrt() { ); })} + + + + +

{t("labels.or")}

+ + + ); } diff --git a/components/subtitle/track-tabs.tsx b/components/subtitle/track-tabs.tsx index e3284a3..580f73c 100644 --- a/components/subtitle/track-tabs.tsx +++ b/components/subtitle/track-tabs.tsx @@ -5,11 +5,13 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import TranscriptImportDialog from "@/components/transcript-import-dialog"; import { getTrackColor, getTrackHandleColor } from "@/lib/track-colors"; import type { SubtitleTrack } from "@/types/subtitle"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; import type { Dispatch, RefObject, SetStateAction } from "react"; +import { IconFileText } from "@tabler/icons-react"; interface TrackTabsProps { tracks: SubtitleTrack[]; @@ -76,6 +78,16 @@ export default function TrackTabs({ > {t("labels.startFromScratch")} +

{t("labels.or")}

+ + + ); } diff --git a/components/transcript-import-dialog.tsx b/components/transcript-import-dialog.tsx new file mode 100644 index 0000000..e911326 --- /dev/null +++ b/components/transcript-import-dialog.tsx @@ -0,0 +1,375 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + useSubtitleActionsContext, + useSubtitleState, +} from "@/context/subtitle-context"; +import { + segmentTranscriptToSubtitles, + TRANSCRIPT_SEGMENTATION_DEFAULTS, + type TranscriptSegmentationMode, + type TranscriptSegmentationOptions, +} from "@/lib/transcript-segmentation"; +import { IconFileText, IconUpload } from "@tabler/icons-react"; +import { useTranslations } from "next-intl"; +import { + useId, + useMemo, + useState, + type ChangeEvent, + type ReactNode, +} from "react"; + +interface TranscriptImportDialogProps { + children: ReactNode; +} + +const TRACK_LIMIT = 4; + +const parseNumberInput = (value: string, fallback: number): number => { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +const getTxtTrackName = (fileName: string, fallback: string): string => { + const trimmed = fileName.trim(); + if (!trimmed) return fallback; + return trimmed.replace(/\.txt$/i, "") || fallback; +}; + +export default function TranscriptImportDialog({ + children, +}: TranscriptImportDialogProps) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const [transcriptText, setTranscriptText] = useState(""); + const [trackName, setTrackName] = useState(""); + const [mode, setMode] = useState( + TRANSCRIPT_SEGMENTATION_DEFAULTS.mode, + ); + const [startTimeSeconds, setStartTimeSeconds] = useState( + String(TRANSCRIPT_SEGMENTATION_DEFAULTS.startTimeSeconds), + ); + const [cueDurationSeconds, setCueDurationSeconds] = useState( + String(TRANSCRIPT_SEGMENTATION_DEFAULTS.cueDurationSeconds), + ); + const [gapSeconds, setGapSeconds] = useState( + String(TRANSCRIPT_SEGMENTATION_DEFAULTS.gapSeconds), + ); + const [maxCharactersPerCue, setMaxCharactersPerCue] = useState( + String(TRANSCRIPT_SEGMENTATION_DEFAULTS.maxCharactersPerCue), + ); + const fileInputId = useId(); + const { loadSubtitlesIntoTrack, renameTrack, setInitialSubtitles } = + useSubtitleActionsContext(); + const { tracks, activeTrackId } = useSubtitleState(); + + const defaultTrackName = t("transcriptImport.defaultTrackName"); + const effectiveTrackName = trackName.trim() || defaultTrackName; + const activeTrack = activeTrackId + ? tracks.find((track) => track.id === activeTrackId) + : null; + const activeTrackIsEmpty = Boolean( + activeTrack && activeTrack.subtitles.length === 0, + ); + const cannotCreateTrack = tracks.length >= TRACK_LIMIT && !activeTrackIsEmpty; + const destinationText = + activeTrack && activeTrackIsEmpty + ? t("transcriptImport.destinationReplace", { track: activeTrack.name }) + : t("transcriptImport.destinationNewTrack"); + + const segmentationOptions = useMemo( + () => ({ + mode, + startTimeSeconds: parseNumberInput( + startTimeSeconds, + TRANSCRIPT_SEGMENTATION_DEFAULTS.startTimeSeconds, + ), + cueDurationSeconds: parseNumberInput( + cueDurationSeconds, + TRANSCRIPT_SEGMENTATION_DEFAULTS.cueDurationSeconds, + ), + gapSeconds: parseNumberInput( + gapSeconds, + TRANSCRIPT_SEGMENTATION_DEFAULTS.gapSeconds, + ), + maxCharactersPerCue: parseNumberInput( + maxCharactersPerCue, + TRANSCRIPT_SEGMENTATION_DEFAULTS.maxCharactersPerCue, + ), + }), + [ + mode, + startTimeSeconds, + cueDurationSeconds, + gapSeconds, + maxCharactersPerCue, + ], + ); + + const previewSubtitles = useMemo( + () => segmentTranscriptToSubtitles(transcriptText, segmentationOptions), + [transcriptText, segmentationOptions], + ); + + const importDisabled = cannotCreateTrack || previewSubtitles.length === 0; + + const handleFileSelect = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + const text = await file.text(); + setTranscriptText(text); + setTrackName(getTxtTrackName(file.name, defaultTrackName)); + event.currentTarget.value = ""; + }; + + const handleImport = () => { + if (importDisabled) return; + + if (activeTrackId && activeTrackIsEmpty) { + loadSubtitlesIntoTrack(activeTrackId, previewSubtitles); + renameTrack(activeTrackId, effectiveTrackName); + } else { + setInitialSubtitles(previewSubtitles, effectiveTrackName); + } + + setOpen(false); + }; + + return ( + + {children} + + + {t("transcriptImport.title")} + + {t("transcriptImport.description")} + + + +
+
+ +
+ + + + {t("transcriptImport.fileHint")} + +
+
+ +
+ +