2026-02-04 14:16:02 +07:00
< template >
< div class = "min-h-screen bg-gray-50 p-4 md:p-8" >
< div class = "max-w-4xl mx-auto" >
2026-02-27 10:05:33 +07:00
<!-- ส ่ วนห ั ว / ช ื ่ อเร ื ่ อง ( Header / Title ) -- >
2026-02-04 14:16:02 +07:00
< div class = "mb-6 flex items-center justify-between" >
< div >
< h1 class = "text-2xl font-bold text-gray-800" > Quiz Runner < / h1 >
< p class = "text-gray-500" > Subject : General Knowledge < / p >
< / div >
< div class = "text-right hidden md:block" >
< div class = "text-sm text-gray-400" > Time Elapsed < / div >
< div class = "font-mono text-xl" > 10 : 05 < / div >
< / div >
< / div >
< div class = "grid grid-cols-1 lg:grid-cols-12 gap-6" >
2026-02-27 10:05:33 +07:00
<!-- แถบด ้ านข ้ าง : ต ั วนำทางคำถาม ( Sidebar : Question Navigator ) -- >
2026-02-04 14:16:02 +07:00
< div class = "lg:col-span-3 order-2 lg:order-1" >
< QCard class = "bg-white shadow-sm sticky top-4" >
< QCardSection >
< div class = "text-subtitle1 font-bold mb-3" > Questions < / div >
< div class = "grid grid-cols-5 gap-2" >
< button
v - for = "(q, index) in store.questions"
: key = "q.id"
@ click = "store.goToQuestion(index)"
class = "w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-all border-2"
: class = "getIndicatorClass(index, q.id)"
>
{ { index + 1 } }
< / button >
< / div >
2026-02-27 10:05:33 +07:00
<!-- คำอธ ิ บายส ั ญล ั กษณ ์ ( Legend ) -- >
2026-02-04 14:16:02 +07:00
< div class = "mt-6 space-y-2 text-xs text-gray-600" >
< div class = "flex items-center gap-2" > < div class = "w-3 h-3 rounded-full bg-blue-500" > < / div > Current < / div >
< div class = "flex items-center gap-2" > < div class = "w-3 h-3 rounded-full bg-green-500" > < / div > Completed < / div >
< div class = "flex items-center gap-2" > < div class = "w-3 h-3 rounded-full bg-orange-500" > < / div > Skipped < / div >
< div class = "flex items-center gap-2" > < div class = "w-3 h-3 rounded-full bg-gray-300" > < / div > Not Started < / div >
< / div >
< / QCardSection >
< / QCard >
< / div >
2026-02-27 10:05:33 +07:00
<!-- เน ื ้ อหาหล ั ก : คำถาม ( Main Content : Question ) -- >
2026-02-04 14:16:02 +07:00
< div class = "lg:col-span-9 order-1 lg:order-2" >
< QCard v-if = "store.currentQuestion" class="bg-white shadow-md min-h-[400px] flex flex-col" >
2026-02-27 10:05:33 +07:00
<!-- ส ่ วนห ั วคำถาม ( Question Header ) -- >
2026-02-04 14:16:02 +07:00
< QCardSection class = "bg-gray-50 border-b border-gray-100 py-4" >
< div class = "flex justify-between items-start" >
< div >
< QBadge : color = "store.currentQuestion.is_skippable ? 'orange' : 'red'" class = "mb-2" >
{ { store . currentQuestion . is _skippable ? 'Optional' : 'Required' } }
< / QBadge >
< h2 class = "text-xl font-medium text-gray-800" >
< span class = "text-gray-400 mr-2" > { { store . currentQuestionIndex + 1 } } . < / span >
2026-02-27 10:05:33 +07:00
{ { getLocalizedString ( store . currentQuestion . question ) } }
2026-02-04 14:16:02 +07:00
< / h2 >
< / div >
< / div >
< / QCardSection >
2026-02-27 10:05:33 +07:00
<!-- ส ่ วนเน ื ้ อหาคำถาม ( Question Body ) -- >
2026-02-04 14:16:02 +07:00
< QCardSection class = "flex-grow py-8 px-6" >
2026-02-27 10:05:33 +07:00
<!-- เล ื อกคำตอบเด ี ยว ( Single Choice ) -- >
2026-02-04 14:16:02 +07:00
< div v-if = "store.currentQuestion.type === 'single'" >
2026-02-27 10:05:33 +07:00
< div v-for = "opt in store.currentQuestion.choices" :key="opt.id"
2026-02-04 14:16:02 +07:00
class = "mb-3 p-3 border rounded-lg hover:bg-blue-50 cursor-pointer transition-colors"
: class = "{ 'border-blue-500 bg-blue-50': currentVal === opt.id, 'border-gray-200': currentVal !== opt.id }"
@ click = "handleInput(opt.id)" >
< div class = "flex items-center" >
< div class = "w-5 h-5 rounded-full border flex items-center justify-center mr-3"
: class = "{ 'border-blue-500': currentVal === opt.id, 'border-gray-300': currentVal !== opt.id }" >
< div v-if = "currentVal === opt.id" class="w-2.5 h-2.5 rounded-full bg-blue-500" > < / div >
< / div >
2026-02-27 10:05:33 +07:00
< span class = "text-gray-700" > { { getLocalizedString ( opt . text ) } } < / span >
2026-02-04 14:16:02 +07:00
< / div >
< / div >
< / div >
2026-02-27 10:05:33 +07:00
<!-- เล ื อกหลายคำตอบ ( Multiple Choice ) -- >
2026-02-04 14:16:02 +07:00
< div v -else -if = " store.currentQuestion.type = = = ' multiple ' " >
2026-02-27 10:05:33 +07:00
< div v-for = "opt in store.currentQuestion.choices" :key="opt.id"
2026-02-04 14:16:02 +07:00
class = "mb-3 p-3 border rounded-lg hover:bg-blue-50 cursor-pointer transition-colors"
: class = "{ 'border-blue-500 bg-blue-50': isSelected(opt.id), 'border-gray-200': !isSelected(opt.id) }"
@ click = "toggleSelection(opt.id)" >
< div class = "flex items-center" >
< div class = "w-5 h-5 rounded border flex items-center justify-center mr-3"
: class = "{ 'border-blue-500 bg-blue-500': isSelected(opt.id), 'border-gray-300': !isSelected(opt.id) }" >
< QIcon v-if = "isSelected(opt.id)" name="check" class="text-white text-xs" / >
< / div >
2026-02-27 10:05:33 +07:00
< span class = "text-gray-700" > { { getLocalizedString ( opt . text ) } } < / span >
2026-02-04 14:16:02 +07:00
< / div >
< / div >
< / div >
2026-02-27 10:05:33 +07:00
<!-- พ ิ มพ ์ ข ้ อความ ( Text Input ) -- >
2026-02-04 14:16:02 +07:00
< div v -else -if = " store.currentQuestion.type = = = ' text ' " >
< QInput
v - model = "textModel"
type = "textarea"
outlined
rows = "6"
placeholder = "Type your answer here..."
class = "w-full text-lg"
@ update : model - value = "handleTextInput"
/ >
< / div >
< / QCardSection >
2026-02-27 10:05:33 +07:00
<!-- แบนเนอร ์ แสดงข ้ อผ ิ ดพลาด ( Error Banner ) -- >
2026-02-04 14:16:02 +07:00
< QBanner v-if = "store.lastError" class="bg-red-50 text-red-600 px-6 py-2 border-t border-red-100" >
< template v -slot : avatar >
< QIcon name = "warning" color = "red" / >
< / template >
{ { store . lastError } }
< / QBanner >
2026-02-27 10:05:33 +07:00
<!-- ส ่ วนท ้ ายป ุ ่ มกดต ่ างๆ ( Actions Footer ) -- >
2026-02-04 14:16:02 +07:00
< QCardSection class = "border-t border-gray-100 bg-gray-50 p-4 flex flex-wrap gap-4 items-center justify-between" >
< QBtn
flat
color = "grey-7"
label = "Previous"
icon = "arrow_back"
: disable = "store.isFirstQuestion"
@ click = "store.prevQuestion()"
no - caps
/ >
< div class = "flex gap-3" >
< QBtn
v - if = "store.currentQuestion.is_skippable && store.answers[store.currentQuestion.id]?.status !== 'completed'"
flat
color = "orange-8"
label = "Skip for now"
2026-02-27 10:05:33 +07:00
@ click = "store.nextQuestion()"
2026-02-04 14:16:02 +07:00
no - caps
/ >
< QBtn
unelevated
: loading = "store.loading"
: color = "isSaved ? 'green' : 'primary'"
: label = "isSaved ? 'Saved' : 'Save Answer'"
: icon = "isSaved ? 'check' : 'save'"
class = "px-6"
@ click = "store.saveCurrentAnswer()"
no - caps
/ >
< QBtn
unelevated
color = "grey-9"
label = "Next"
icon - right = "arrow_forward"
: disable = "store.isLastQuestion"
@ click = "store.nextQuestion()"
no - caps
/ >
< / div >
< / QCardSection >
< / QCard >
< / div >
< / div >
< / div >
< / div >
< / template >
< script setup lang = "ts" >
import { computed , ref , onMounted , watch , reactive } from 'vue' ;
import { useRoute } from 'vue-router' ;
2026-02-27 10:05:33 +07:00
// คอมโพสิเบิลถูกนำเข้าอัตโนมัติใน Nuxt (Composable is auto-imported in Nuxt)
2026-02-04 14:16:02 +07:00
// import { useQuizRunner } from '@/composables/useQuizRunner';
const route = useRoute ( ) ;
2026-02-27 10:05:33 +07:00
// ห่อหุ้มใน reactive เพื่อแกะค่า refs ทำให้เหมือนพฤติกรรม Pinia store สำหรับ template (Wrap in reactive to unwrap refs, mimicking Pinia store behavior for template)
2026-02-04 14:16:02 +07:00
const store = reactive ( useQuizRunner ( ) ) ;
onMounted ( ( ) => {
const quizId = route . params . id as string ;
store . initQuiz ( quizId ) ;
} ) ;
2026-02-27 10:05:33 +07:00
// -- ตัวช่วยสำหรับการจัดการการป้อนข้อมูล (Helpers for Input Handling) --
// ฟังก์ชันช่วยเหลือสำหรับแปลภาษา (Helper to safely format text)
const getLocalizedString = ( val : any ) : string => {
if ( typeof val === 'string' ) return val ;
if ( val && typeof val === 'object' ) {
return val . th || val . en || String ( val ) ;
}
return String ( val || '' ) ;
}
2026-02-04 14:16:02 +07:00
const currentVal = computed ( ( ) => {
return store . currentAnswer ? . value ;
} ) ;
const isSaved = computed ( ( ) => {
return store . currentAnswer ? . is _saved ;
} ) ;
2026-02-27 10:05:33 +07:00
// เลือกคำตอบเดียว (Single Choice)
function handleInput ( val : number ) {
2026-02-04 14:16:02 +07:00
store . updateAnswer ( val ) ;
}
2026-02-27 10:05:33 +07:00
// พิมพ์ข้อความ (Text Choice)
2026-02-04 14:16:02 +07:00
const textModel = ref ( '' ) ;
2026-02-27 10:05:33 +07:00
// จับตาดูการเปลี่ยนแปลงคำถามเพื่อล้างค่า (Watch for question changes to reset text model)
2026-02-04 14:16:02 +07:00
watch (
( ) => store . currentQuestionIndex ,
( ) => {
if ( store . currentQuestion ? . type === 'text' ) {
textModel . value = ( store . currentAnswer ? . value as string ) || '' ;
}
2026-02-27 10:05:33 +07:00
// ล้างข้อผิดพลาดเมื่อเปลี่ยนคำถาม (Clear error when changing question)
2026-02-04 14:16:02 +07:00
store . lastError = null ;
2026-02-27 10:05:33 +07:00
// เลื่อนหน้าจอขึ้นบนสุด (Scroll to top)
2026-02-04 14:16:02 +07:00
if ( typeof window !== 'undefined' ) {
window . scrollTo ( { top : 0 , behavior : 'smooth' } ) ;
}
} ,
{ immediate : true }
) ;
2026-02-27 10:05:33 +07:00
// จับตาดูข้อผิดพลาดเพื่อเลื่อนหน้าจอไปยังฟิลด์นั้น (Watch for error to scroll to error/field)
2026-02-04 14:16:02 +07:00
watch (
( ) => store . lastError ,
( newVal ) => {
if ( newVal ) {
if ( typeof document !== 'undefined' ) {
setTimeout ( ( ) => {
const errorEl = document . querySelector ( '.q-banner' ) ;
if ( errorEl ) {
errorEl . scrollIntoView ( { behavior : 'smooth' , block : 'center' } ) ;
}
} , 100 ) ;
}
}
}
)
function handleTextInput ( val : string | number | null ) {
store . updateAnswer ( val as string ) ;
}
2026-02-27 10:05:33 +07:00
// เลือกหลายคำตอบ (Multiple Choice)
function isSelected ( id : number ) {
2026-02-04 14:16:02 +07:00
const val = store . currentAnswer ? . value ;
if ( Array . isArray ( val ) ) {
return val . includes ( id ) ;
}
return false ;
}
2026-02-27 10:05:33 +07:00
function toggleSelection ( id : number ) {
2026-02-04 14:16:02 +07:00
const val = store . currentAnswer ? . value ;
2026-02-27 10:05:33 +07:00
let currentArr : number [ ] = [ ] ;
2026-02-04 14:16:02 +07:00
if ( Array . isArray ( val ) ) {
currentArr = [ ... val ] ;
}
const idx = currentArr . indexOf ( id ) ;
if ( idx >= 0 ) {
currentArr . splice ( idx , 1 ) ;
} else {
currentArr . push ( id ) ;
}
store . updateAnswer ( currentArr ) ;
}
2026-02-27 10:05:33 +07:00
// -- ตัวช่วยสำหรับการจัดแต่งทรง (Helpers for Styling) --
2026-02-04 14:16:02 +07:00
function getIndicatorClass ( index : number , qId : number ) {
2026-02-27 10:05:33 +07:00
// 1. ปัจจุบัน = สีน้ำเงิน (Current = Blue)
2026-02-04 14:16:02 +07:00
if ( index === store . currentQuestionIndex ) {
return 'bg-blue-500 text-white border-blue-600' ;
}
const status = store . answers [ qId ] ? . status || 'not_started' ;
switch ( status ) {
case 'completed' :
return 'bg-green-500 text-white border-green-600' ;
case 'skipped' :
return 'bg-orange-500 text-white border-orange-600' ;
case 'in_progress' :
2026-02-27 10:05:33 +07:00
// กรณีที่เป็น in_progress แต่ไม่ใช่คำถามปัจจุบัน (จริงๆ เกิดยากตามตรรกะที่เข้มงวด แต่เผื่อไว้ก่อน)
// (If it's in_progress but NOT current (should be rare/impossible with strict logic, but handled))
2026-02-04 14:16:02 +07:00
return 'bg-blue-200 text-blue-800 border-blue-300' ;
case 'not_started' :
default :
return 'bg-gray-200 text-gray-400 border-gray-300 hover:bg-gray-300' ;
}
}
< / script >
< style scoped >
2026-02-27 10:05:33 +07:00
/* ส่วนเสริม: ทรานสิชั่น (Optional: Transitions) */
2026-02-04 14:16:02 +07:00
< / style >