media preview fix

This commit is contained in:
Yared Yemane 2026-04-07 10:59:40 -07:00
parent 4210a05ba9
commit d1842579e9
4 changed files with 853 additions and 200 deletions

21
src/lib/practiceMedia.ts Normal file
View File

@ -0,0 +1,21 @@
import { resolveFileUrl } from "../api/files.api"
export function normalizeObjectKey(value: string): string {
const trimmed = value.trim()
if (!trimmed) return ""
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return trimmed
const protocolMatch = trimmed.match(/^[a-z]+:\/\//i)
if (protocolMatch) {
return trimmed.replace(/^[a-z]+:\/\//i, "")
}
return trimmed
}
export async function resolveMediaPreviewUrl(value: string): Promise<string> {
if (!value.trim()) return ""
if (value.startsWith("http://") || value.startsWith("https://")) return value
const key = normalizeObjectKey(value)
if (!key) return ""
const res = await resolveFileUrl(key)
return res.data?.data?.url ?? ""
}

View File

@ -40,6 +40,7 @@ interface Question {
sampleAnswerVoicePrompt: string sampleAnswerVoicePrompt: string
audioCorrectAnswerText: string audioCorrectAnswerText: string
shortAnswers: string[] shortAnswers: string[]
imageUrl: string
} }
const PERSONAS: Persona[] = [ const PERSONAS: Persona[] = [
@ -91,6 +92,7 @@ function createEmptyQuestion(id: string): Question {
sampleAnswerVoicePrompt: "", sampleAnswerVoicePrompt: "",
audioCorrectAnswerText: "", audioCorrectAnswerText: "",
shortAnswers: [], shortAnswers: [],
imageUrl: "",
} }
} }
@ -232,6 +234,7 @@ export function AddNewPracticePage() {
voice_prompt: q.voicePrompt || undefined, voice_prompt: q.voicePrompt || undefined,
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined, sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined,
audio_correct_answer_text: q.audioCorrectAnswerText || undefined, audio_correct_answer_text: q.audioCorrectAnswerText || undefined,
image_url: q.imageUrl.trim() || undefined,
short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined, short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
}) })
@ -606,6 +609,7 @@ export function AddNewPracticePage() {
sampleAnswerVoicePrompt: question.sampleAnswerVoicePrompt, sampleAnswerVoicePrompt: question.sampleAnswerVoicePrompt,
audioCorrectAnswerText: question.audioCorrectAnswerText, audioCorrectAnswerText: question.audioCorrectAnswerText,
shortAnswer: question.shortAnswers[0] ?? "", shortAnswer: question.shortAnswers[0] ?? "",
imageUrl: question.imageUrl,
}} }}
onChange={(next) => { onChange={(next) => {
updateQuestion(question.id, { updateQuestion(question.id, {
@ -620,8 +624,10 @@ export function AddNewPracticePage() {
sampleAnswerVoicePrompt: next.sampleAnswerVoicePrompt, sampleAnswerVoicePrompt: next.sampleAnswerVoicePrompt,
audioCorrectAnswerText: next.audioCorrectAnswerText, audioCorrectAnswerText: next.audioCorrectAnswerText,
shortAnswers: next.shortAnswer.trim() ? [next.shortAnswer.trim()] : [], shortAnswers: next.shortAnswer.trim() ? [next.shortAnswer.trim()] : [],
imageUrl: next.imageUrl,
}) })
}} }}
mediaBusy={saving}
/> />
</Card> </Card>
))} ))}

View File

@ -210,7 +210,6 @@ export function HumanLanguagePage() {
const [questionDialog, setQuestionDialog] = useState<QuestionDialogState>({ open: false }) const [questionDialog, setQuestionDialog] = useState<QuestionDialogState>({ open: false })
const [practiceForm, setPracticeForm] = useState({ title: "", description: "", persona: "" }) const [practiceForm, setPracticeForm] = useState({ title: "", description: "", persona: "" })
const [questionDraft, setQuestionDraft] = useState<PracticeQuestionEditorValue>(() => createEmptyPracticeQuestionDraft()) const [questionDraft, setQuestionDraft] = useState<PracticeQuestionEditorValue>(() => createEmptyPracticeQuestionDraft())
const [questionImageUrl, setQuestionImageUrl] = useState("")
const [questionDetailById, setQuestionDetailById] = useState<Record<number, QuestionDetail>>({}) const [questionDetailById, setQuestionDetailById] = useState<Record<number, QuestionDetail>>({})
const [practiceTargetDelete, setPracticeTargetDelete] = useState<{ id: number; title: string } | null>(null) const [practiceTargetDelete, setPracticeTargetDelete] = useState<{ id: number; title: string } | null>(null)
const [questionTargetDelete, setQuestionTargetDelete] = useState<{ id: number; practiceId: number; text: string } | null>(null) const [questionTargetDelete, setQuestionTargetDelete] = useState<{ id: number; practiceId: number; text: string } | null>(null)
@ -597,7 +596,6 @@ export function HumanLanguagePage() {
const resetPracticeForm = () => setPracticeForm({ title: "", description: "", persona: "" }) const resetPracticeForm = () => setPracticeForm({ title: "", description: "", persona: "" })
const resetQuestionForm = () => { const resetQuestionForm = () => {
setQuestionDraft(createEmptyPracticeQuestionDraft()) setQuestionDraft(createEmptyPracticeQuestionDraft())
setQuestionImageUrl("")
} }
const openCreatePracticeDialog = (subModuleId: number) => { const openCreatePracticeDialog = (subModuleId: number) => {
@ -680,7 +678,6 @@ export function HumanLanguagePage() {
return return
} }
setQuestionDetailById((prev) => ({ ...prev, [qid]: detail })) setQuestionDetailById((prev) => ({ ...prev, [qid]: detail }))
setQuestionImageUrl(detail.image_url ?? "")
const sortedOpts = (detail.options ?? []).slice().sort((a, b) => a.option_order - b.option_order) const sortedOpts = (detail.options ?? []).slice().sort((a, b) => a.option_order - b.option_order)
const shortAnswer = const shortAnswer =
Array.isArray(detail.short_answers) && detail.short_answers.length > 0 Array.isArray(detail.short_answers) && detail.short_answers.length > 0
@ -738,6 +735,7 @@ export function HumanLanguagePage() {
sampleAnswerVoicePrompt: detail.sample_answer_voice_prompt ?? "", sampleAnswerVoicePrompt: detail.sample_answer_voice_prompt ?? "",
audioCorrectAnswerText: detail.audio_correct_answer_text ?? "", audioCorrectAnswerText: detail.audio_correct_answer_text ?? "",
shortAnswer, shortAnswer,
imageUrl: detail.image_url ?? "",
}) })
// Open only after the same form shape as create is fully populated (no empty-state flash). // Open only after the same form shape as create is fully populated (no empty-state flash).
setQuestionDialog({ open: true, mode: "edit", practiceId, questionId: qid }) setQuestionDialog({ open: true, mode: "edit", practiceId, questionId: qid })
@ -758,7 +756,7 @@ export function HumanLanguagePage() {
points: Number(d.points) || 1, points: Number(d.points) || 1,
tips: d.tips.trim() || undefined, tips: d.tips.trim() || undefined,
explanation: d.explanation.trim() || undefined, explanation: d.explanation.trim() || undefined,
image_url: questionImageUrl.trim() || undefined, image_url: d.imageUrl.trim() || undefined,
voice_prompt: d.voicePrompt.trim() || undefined, voice_prompt: d.voicePrompt.trim() || undefined,
sample_answer_voice_prompt: d.sampleAnswerVoicePrompt.trim() || undefined, sample_answer_voice_prompt: d.sampleAnswerVoicePrompt.trim() || undefined,
audio_correct_answer_text: d.audioCorrectAnswerText.trim() || undefined, audio_correct_answer_text: d.audioCorrectAnswerText.trim() || undefined,
@ -1917,21 +1915,8 @@ export function HumanLanguagePage() {
}} }}
fieldErrors={questionFieldErrors} fieldErrors={questionFieldErrors}
showFieldErrors={questionSubmitAttempted || questionFormTouched} showFieldErrors={questionSubmitAttempted || questionFormTouched}
mediaBusy={savingQuestion}
/> />
<div className="mt-5 space-y-2 border-t border-grayScale-100 pt-5">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Image URL (Optional)</label>
<Input
value={questionImageUrl}
onChange={(e) => {
setQuestionFormTouched(true)
setQuestionImageUrl(e.target.value)
}}
placeholder="https://…"
type="url"
className="h-11 font-mono text-[13px]"
/>
</div>
</Card> </Card>
</div> </div>