diff --git a/surfsense_web/components/tool-ui/podcast/brief-review.tsx b/surfsense_web/components/tool-ui/podcast/brief-review.tsx index 3473b64d6..d662aebc2 100644 --- a/surfsense_web/components/tool-ui/podcast/brief-review.tsx +++ b/surfsense_web/components/tool-ui/podcast/brief-review.tsx @@ -1,11 +1,20 @@ "use client"; -import { Loader2, Plus, Trash2 } from "lucide-react"; +import { Check, ChevronDown, Loader2, Plus, Trash2 } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, @@ -15,6 +24,7 @@ import { } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { + type LanguageOptions, MAX_SPEAKERS, type PodcastSpec, type PodcastStyle, @@ -56,6 +66,7 @@ interface BriefReviewProps { export function BriefReview({ podcast, spec }: BriefReviewProps) { const [draft, setDraft] = useState(spec); const [voices, setVoices] = useState(null); + const [offering, setOffering] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); // A pushed spec change (saved edit or concurrent editor) resets the form to @@ -75,19 +86,26 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) { .catch(() => { if (!cancelled) setVoices([]); }); + podcastsApiService + .listLanguages() + .then((options) => { + if (!cancelled) setOffering(options); + }) + .catch(() => { + if (!cancelled) setOffering({ languages: [], allows_custom: false }); + }); return () => { cancelled = true; }; }, []); + // The backend owns the offering; the draft's language stays listed even + // when it falls outside it (e.g. a custom tag entered earlier). const languages = useMemo(() => { - const tags = new Set(); - for (const voice of voices ?? []) { - if (voice.language !== ANY_LANGUAGE) tags.add(voice.language); - } + const tags = new Set(offering?.languages ?? []); tags.add(draft.language); return [...tags].sort(); - }, [voices, draft.language]); + }, [offering, draft.language]); const voicesForLanguage = useMemo( () => (voices ?? []).filter((voice) => speaks(voice, draft.language)), @@ -193,18 +211,22 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
- + {offering?.allows_custom ? ( + + ) : ( + + )}
@@ -375,6 +397,80 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) { ); } +/** A searchable language picker for providers whose voices speak anything: + * the offered list comes from the backend, and any BCP-47 tag may be typed + * when none of them fits. */ +function LanguageCombobox({ + value, + languages, + onSelect, +}: { + value: string; + languages: string[]; + onSelect: (language: string) => void; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + + const pick = (tag: string) => { + onSelect(tag); + setOpen(false); + setQuery(""); + }; + + const customTag = query.trim(); + const isNewTag = + customTag.length > 0 && !languages.some((tag) => tag.toLowerCase() === customTag.toLowerCase()); + + return ( + + + + + + + + + No matching language. + + {languages.map((tag) => ( + pick(tag)} + > + + {languageLabel(tag)} + + ))} + {isNewTag ? ( + pick(customTag)}> + + Use “{customTag}” + + ) : null} + + + + + + ); +} + /** The current selection stays listed even when it no longer matches the * language filter, so the Select never renders an orphaned value. */ function voiceItems(candidates: VoiceOption[], selectedId: string): VoiceOption[] {