ishaq101's picture
feat:interview (#1)
fe828ac
import { useCallback, useEffect, useRef, useState } from "react";
import { Bot, User, Send, Loader2, Mic, MicOff, CheckCircle2, RotateCcw, AlertTriangle, RefreshCw, ArrowRight } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown";
import AudioRecorder from "./AudioRecorder";
import { getInterviewResult, type InterviewResult } from "../../../services/interviewApi";
import {
useInterviewSession,
type InterviewMessage,
type InterviewMode,
} from "../../../hooks/useInterviewSession";
const markdownComponents: Components = {
p: ({ children }) => (
<p className="text-sm mb-1.5 last:mb-0 leading-relaxed">{children}</p>
),
ul: ({ children }) => (
<ul className="list-disc pl-4 mb-1.5 space-y-0.5 text-sm">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal pl-4 mb-1.5 space-y-0.5 text-sm">{children}</ol>
),
li: ({ children }) => <li>{children}</li>,
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
};
interface InterviewPanelProps {
roomId: string;
userId: string;
onComplete: () => void;
onResultReady?: (result: InterviewResult) => void;
}
export default function InterviewPanel({ roomId, userId, onComplete, onResultReady }: InterviewPanelProps) {
const {
status,
messages,
isSending,
isStarting,
startError,
interviewResult,
isLoaded,
startSession,
sendTextMessage,
connectAudio,
disconnectAudio,
sendAudioChunk,
sendEndUtterance,
switchMode,
resetSession,
} = useInterviewSession(roomId, userId);
const [input, setInput] = useState("");
const [audioMode, setAudioMode] = useState<InterviewMode>("text");
const [audioStreamingText, setAudioStreamingText] = useState("");
const [isAudioConnected, setIsAudioConnected] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, audioStreamingText]);
// Auto-start hanya setelah localStorage selesai dimuat dan status benar-benar idle
useEffect(() => {
if (isLoaded && status === "idle") {
startSession("text");
}
}, [isLoaded, status]); // eslint-disable-line react-hooks/exhaustive-deps
// Notify parent when result is ready (from hook or re-fetched)
useEffect(() => {
if (status === "completed" && interviewResult) {
onResultReady?.(interviewResult);
} else if (status === "completed" && !interviewResult) {
getInterviewResult(roomId)
.then((r) => onResultReady?.(r))
.catch(() => {});
}
}, [status, interviewResult]); // eslint-disable-line react-hooks/exhaustive-deps
// Setup audio WebSocket when audio mode is active
useEffect(() => {
if (audioMode !== "audio" || status !== "active") return;
if (isAudioConnected) return;
setIsAudioConnected(true);
connectAudio(
(token) => setAudioStreamingText((prev) => prev + token),
(fullText) => {
setAudioStreamingText("");
// The hook handles adding the message internally, but for audio we add it here
// since audio messages arrive differently
void fullText; // handled via useInterviewSession internal state if needed
},
async (audioBuf) => {
// Play TTS audio
try {
if (!audioContextRef.current || audioContextRef.current.state === "closed") {
audioContextRef.current = new AudioContext();
}
const ctx = audioContextRef.current;
const decoded = await ctx.decodeAudioData(audioBuf.slice(0));
const source = ctx.createBufferSource();
source.buffer = decoded;
source.connect(ctx.destination);
source.start();
} catch {
// ignore playback errors
}
},
() => {
setIsAudioConnected(false);
onComplete();
}
);
return () => {
disconnectAudio();
setIsAudioConnected(false);
};
}, [audioMode, status]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSendText = useCallback(async () => {
const text = input.trim();
if (!text || isSending || status !== "active") return;
setInput("");
await sendTextMessage(text);
inputRef.current?.focus();
}, [input, isSending, status, sendTextMessage]);
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendText();
}
};
const handleModeToggle = () => {
const next: InterviewMode = audioMode === "text" ? "audio" : "text";
setAudioMode(next);
switchMode(next);
setIsAudioConnected(false);
setAudioStreamingText("");
};
const handleRestart = () => {
setAudioMode("text");
setIsAudioConnected(false);
setAudioStreamingText("");
resetSession();
startSession("text");
};
// Loading state saat memanggil API untuk membuat sesi
if (isStarting) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-slate-500">
<Loader2 className="w-6 h-6 animate-spin text-emerald-600" />
<p className="text-sm">Memulai sesi interview…</p>
</div>
);
}
// Error state — backend tidak bisa dijangkau
if (startError && status === "idle") {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-4 px-6">
<div className="flex flex-col items-center gap-2 text-center">
<AlertTriangle className="w-8 h-8 text-amber-400" />
<p className="text-sm font-medium text-slate-700">Tidak dapat terhubung ke server interview</p>
<p className="text-xs text-slate-400 max-w-xs">
Pastikan backend interview berjalan di <code className="bg-slate-100 px-1 py-0.5 rounded text-xs">localhost:8080</code>
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => startSession("text")}
className="flex items-center gap-1.5 px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-xs rounded-lg transition"
>
<RefreshCw className="w-3.5 h-3.5" />
Coba Lagi
</button>
<button
onClick={onComplete}
className="flex items-center gap-1.5 px-3 py-2 bg-slate-100 hover:bg-slate-200 text-slate-600 text-xs rounded-lg transition"
>
Lanjut ke Analytics
<ArrowRight className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
}
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Mode toggle bar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-slate-100 bg-white/60 backdrop-blur-sm">
<p className="text-xs text-slate-500">
{status === "completed"
? "Interview selesai — lihat hasil di bawah"
: "Jawab pertanyaan untuk membantu analisis data Anda"}
</p>
<div className="flex items-center gap-2">
{status === "completed" && (
<button
onClick={handleRestart}
className="flex items-center gap-1 text-xs text-slate-400 hover:text-slate-600 transition"
>
<RotateCcw className="w-3 h-3" />
Mulai ulang
</button>
)}
{status === "active" && (
<button
onClick={handleModeToggle}
title={audioMode === "text" ? "Beralih ke mode audio" : "Beralih ke mode teks"}
className={`flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border transition-all duration-200 ${
audioMode === "audio"
? "bg-emerald-50 border-emerald-200 text-emerald-700"
: "bg-slate-50 border-slate-200 text-slate-500 hover:border-slate-300"
}`}
>
{audioMode === "audio" ? (
<Mic className="w-3 h-3" />
) : (
<MicOff className="w-3 h-3" />
)}
<span>{audioMode === "audio" ? "Audio" : "Teks"}</span>
</button>
)}
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
{/* Live audio streaming text */}
{audioStreamingText && (
<div className="flex gap-2.5 max-w-[85%]">
<div className="w-7 h-7 rounded-full bg-emerald-100 flex items-center justify-center flex-shrink-0 mt-0.5">
<Bot className="w-4 h-4 text-emerald-600" />
</div>
<div className="bg-white border border-slate-200 rounded-2xl rounded-tl-sm px-3.5 py-2.5 shadow-sm">
<p className="text-sm text-slate-700 leading-relaxed">
{audioStreamingText}
<span className="inline-block w-1 h-3.5 bg-emerald-500 ml-0.5 animate-pulse rounded-sm" />
</p>
</div>
</div>
)}
{/* Completed state */}
{status === "completed" && (
<div className="flex justify-center py-4">
<div className="flex items-center gap-2 text-emerald-600 bg-emerald-50 border border-emerald-200 rounded-full px-4 py-2 text-sm">
<CheckCircle2 className="w-4 h-4" />
<span>Interview selesai!</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* CTA setelah interview selesai */}
{status === "completed" && (
<div className="border-t border-slate-100 px-4 py-3 flex justify-end">
<button
onClick={onComplete}
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm rounded-xl transition"
>
Lanjut ke Analytics
<ArrowRight className="w-4 h-4" />
</button>
</div>
)}
{/* Input area */}
{status === "active" && (
<div className="border-t border-slate-200 bg-white/80 backdrop-blur-sm p-3">
{audioMode === "audio" ? (
<div className="flex items-center justify-center gap-3 py-2">
<p className="text-sm text-slate-500">
Tahan tombol mikrofon lalu bicara
</p>
<AudioRecorder
onChunk={sendAudioChunk}
onEndUtterance={sendEndUtterance}
disabled={isSending}
/>
</div>
) : (
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="Ketik jawaban Anda…"
rows={1}
disabled={isSending}
className="flex-1 resize-none bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-emerald-400 focus:border-transparent transition max-h-32 disabled:opacity-60"
style={{ minHeight: "42px" }}
onInput={(e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 128)}px`;
}}
/>
<button
onClick={handleSendText}
disabled={!input.trim() || isSending}
className="w-9 h-9 rounded-full bg-emerald-600 hover:bg-emerald-700 disabled:bg-slate-200 disabled:cursor-not-allowed text-white flex items-center justify-center flex-shrink-0 transition-all duration-200 hover:scale-105 disabled:scale-100"
>
{isSending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
</button>
</div>
)}
</div>
)}
</div>
);
}
function MessageBubble({ message }: { message: InterviewMessage }) {
const isUser = message.role === "user";
if (isUser) {
return (
<div className="flex gap-2.5 max-w-[85%] ml-auto flex-row-reverse">
<div className="w-7 h-7 rounded-full bg-blue-600 flex items-center justify-center flex-shrink-0 mt-0.5">
<User className="w-4 h-4 text-white" />
</div>
<div className="bg-blue-600 text-white rounded-2xl rounded-tr-sm px-3.5 py-2.5 shadow-sm">
<p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
</div>
</div>
);
}
return (
<div className="flex gap-2.5 max-w-[85%]">
<div className="w-7 h-7 rounded-full bg-emerald-100 flex items-center justify-center flex-shrink-0 mt-0.5">
<Bot className="w-4 h-4 text-emerald-600" />
</div>
<div className="bg-white border border-slate-200 rounded-2xl rounded-tl-sm px-3.5 py-2.5 shadow-sm">
{message.isStreaming && !message.content ? (
<div className="flex gap-1 py-1">
<span className="w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce [animation-delay:0ms]" />
<span className="w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce [animation-delay:150ms]" />
<span className="w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce [animation-delay:300ms]" />
</div>
) : (
<div className="text-slate-700">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{message.content}
</ReactMarkdown>
{message.isStreaming && (
<span className="inline-block w-1 h-3.5 bg-emerald-500 ml-0.5 animate-pulse rounded-sm" />
)}
</div>
)}
</div>
</div>
);
}