diff --git a/surfsense_web/components/tool-ui/podcast/brief-review.tsx b/surfsense_web/components/tool-ui/podcast/brief-review.tsx new file mode 100644 index 000000000..9679fd8fa --- /dev/null +++ b/surfsense_web/components/tool-ui/podcast/brief-review.tsx @@ -0,0 +1,396 @@ +"use client"; + +import { Loader2, Plus, Trash2 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { + MAX_SPEAKERS, + type PodcastSpec, + type PodcastStyle, + podcastStyle, + type SpeakerRole, + speakerRole, + type VoiceOption, +} from "@/contracts/types/podcast.types"; +import type { LivePodcast } from "@/hooks/use-podcast-live"; +import { podcastsApiService } from "@/lib/apis/podcasts-api.service"; +import { AppError } from "@/lib/error"; + +// A "*" voice speaks whatever language the text is in (mirrors the backend +// catalog's ANY_LANGUAGE sentinel). +const ANY_LANGUAGE = "*"; + +function speaks(voice: VoiceOption, language: string): boolean { + if (voice.language === ANY_LANGUAGE) return true; + return primary(voice.language) === primary(language); +} + +function primary(language: string): string { + return language.split("-", 1)[0].trim().toLowerCase(); +} + +interface BriefReviewProps { + podcast: LivePodcast; + spec: PodcastSpec; + onApproved: () => void; +} + +/** + * Gate 1: the pre-filled brief as a near-confirmation. One-click approve is + * the easy path; every field stays overridable and saves through the + * version-guarded PATCH so concurrent edits surface instead of clobbering. + */ +export function BriefReview({ podcast, spec, onApproved }: BriefReviewProps) { + const [draft, setDraft] = useState(spec); + const [voices, setVoices] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + // A pushed spec change (saved edit or concurrent editor) resets the form to + // the authoritative version. + // biome-ignore lint/correctness/useExhaustiveDependencies: reset only when the server version moves + useEffect(() => { + setDraft(spec); + }, [podcast.specVersion]); + + useEffect(() => { + let cancelled = false; + podcastsApiService + .listVoices() + .then((catalog) => { + if (!cancelled) setVoices(catalog); + }) + .catch(() => { + if (!cancelled) setVoices([]); + }); + return () => { + cancelled = true; + }; + }, []); + + const languages = useMemo(() => { + const tags = new Set(); + for (const voice of voices ?? []) { + if (voice.language !== ANY_LANGUAGE) tags.add(voice.language); + } + tags.add(draft.language); + return [...tags].sort(); + }, [voices, draft.language]); + + const voicesForLanguage = useMemo( + () => (voices ?? []).filter((voice) => speaks(voice, draft.language)), + [voices, draft.language] + ); + + const isDirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(spec), [draft, spec]); + + const setLanguage = (language: string) => { + setDraft((current) => { + const candidates = (voices ?? []).filter((voice) => speaks(voice, language)); + // Voices that can't render the new language are remapped so the saved + // spec never pairs a language with an incompatible voice. + const speakers = current.speakers.map((speaker, index) => { + const stillValid = candidates.some((voice) => voice.voice_id === speaker.voice_id); + const fallback = candidates[index % Math.max(candidates.length, 1)]; + return stillValid || !fallback ? speaker : { ...speaker, voice_id: fallback.voice_id }; + }); + return { ...current, language, speakers }; + }); + }; + + const updateSpeaker = (slot: number, change: Partial) => { + setDraft((current) => ({ + ...current, + speakers: current.speakers.map((speaker) => + speaker.slot === slot ? { ...speaker, ...change } : speaker + ), + })); + }; + + const addSpeaker = () => { + setDraft((current) => { + if (current.speakers.length >= MAX_SPEAKERS) return current; + const slot = Math.max(...current.speakers.map((s) => s.slot)) + 1; + const voice = + voicesForLanguage[current.speakers.length % Math.max(voicesForLanguage.length, 1)]; + return { + ...current, + speakers: [ + ...current.speakers, + { + slot, + name: `Speaker ${current.speakers.length + 1}`, + role: "guest" as SpeakerRole, + voice_id: voice?.voice_id ?? current.speakers[0].voice_id, + }, + ], + }; + }); + }; + + const removeSpeaker = (slot: number) => { + setDraft((current) => { + if (current.speakers.length <= 1) return current; + return { + ...current, + speakers: current.speakers.filter((speaker) => speaker.slot !== slot), + }; + }); + }; + + const saveIfDirty = async (): Promise => { + if (!isDirty) return true; + try { + await podcastsApiService.updateSpec(podcast.id, draft, podcast.specVersion); + return true; + } catch (error) { + if (error instanceof AppError && error.status === 409) { + toast.warning("The brief changed elsewhere — reloaded the latest version."); + setDraft(spec); + } else { + toast.error(error instanceof Error ? error.message : "Failed to save the brief"); + } + return false; + } + }; + + const handleSave = async () => { + setIsSubmitting(true); + try { + if (await saveIfDirty()) { + toast.success("Brief saved."); + } + } finally { + setIsSubmitting(false); + } + }; + + const handleApprove = async () => { + setIsSubmitting(true); + try { + if (!(await saveIfDirty())) return; + await podcastsApiService.approveBrief(podcast.id); + onApproved(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to approve the brief"); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+ {draft.speakers.map((speaker) => ( +
+
+ + updateSpeaker(speaker.slot, { name: e.target.value })} + /> +
+
+ + +
+
+ + +
+ +
+ ))} +
+ +
+
+ + + setDraft((current) => ({ + ...current, + duration: { ...current.duration, min_minutes: Number(e.target.value) || 1 }, + })) + } + /> +
+
+ + + setDraft((current) => ({ + ...current, + duration: { + ...current.duration, + max_minutes: Number(e.target.value) || current.duration.min_minutes, + }, + })) + } + /> +
+
+ +
+ +