263 lines
12 KiB
Vue
263 lines
12 KiB
Vue
<script setup>
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
|
import { Head, router } from '@inertiajs/vue3';
|
|
import axios from 'axios';
|
|
|
|
const props = defineProps({
|
|
quiz: Object,
|
|
attempt: Object
|
|
});
|
|
|
|
const currentQuestionIndex = ref(0);
|
|
const answers = ref({});
|
|
const timeLeft = ref(props.quiz.duration_minutes * 60);
|
|
let timer = null;
|
|
|
|
// Initialize answers from existing attempt answers if any
|
|
onMounted(() => {
|
|
// Initialize all questions with empty answers to avoid v-model errors
|
|
props.quiz.questions.forEach(q => {
|
|
answers.value[q.id] = { option_id: null, text_content: '' };
|
|
});
|
|
|
|
// Populate with existing answers
|
|
props.attempt.answers.forEach(ans => {
|
|
answers.value[ans.question_id] = {
|
|
option_id: ans.option_id,
|
|
text_content: ans.text_content || ''
|
|
};
|
|
});
|
|
|
|
startTimer();
|
|
});
|
|
|
|
const startTimer = () => {
|
|
// Calculate elapsed time since start
|
|
const startTime = new Date(props.attempt.started_at).getTime();
|
|
const now = new Date().getTime();
|
|
const elapsedSeconds = Math.floor((now - startTime) / 1000);
|
|
|
|
timeLeft.value = Math.max(0, (props.quiz.duration_minutes * 60) - elapsedSeconds);
|
|
|
|
timer = setInterval(() => {
|
|
if (timeLeft.value > 0) {
|
|
timeLeft.value--;
|
|
} else {
|
|
finishQuiz();
|
|
}
|
|
}, 1000);
|
|
};
|
|
|
|
onUnmounted(() => {
|
|
if (timer) clearInterval(timer);
|
|
});
|
|
|
|
const formatTime = (seconds) => {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
const currentQuestion = computed(() => props.quiz.questions[currentQuestionIndex.value]);
|
|
const progress = computed(() => ((currentQuestionIndex.value + 1) / props.quiz.questions.length) * 100);
|
|
|
|
const saveAnswer = async () => {
|
|
const qid = currentQuestion.value.id;
|
|
const ans = answers.value[qid] || {};
|
|
|
|
try {
|
|
await axios.post(route('attempts.save', props.attempt.id), {
|
|
question_id: qid,
|
|
...ans
|
|
});
|
|
} catch (e) {
|
|
console.error('Save failed', e);
|
|
}
|
|
};
|
|
|
|
const selectOption = (optionId) => {
|
|
answers.value[currentQuestion.value.id] = {
|
|
option_id: optionId,
|
|
text_content: null
|
|
};
|
|
saveAnswer();
|
|
};
|
|
|
|
const updateOpenAnswer = (text) => {
|
|
answers.value[currentQuestion.value.id] = {
|
|
option_id: null,
|
|
text_content: text
|
|
};
|
|
// Debounce save for text content in a real app, here we just save on "Next" or small timeout
|
|
};
|
|
|
|
const nextQuestion = () => {
|
|
if (currentQuestionIndex.value < props.quiz.questions.length - 1) {
|
|
currentQuestionIndex.value++;
|
|
} else {
|
|
finishQuiz();
|
|
}
|
|
};
|
|
|
|
const prevQuestion = () => {
|
|
if (currentQuestionIndex.value > 0) {
|
|
currentQuestionIndex.value--;
|
|
}
|
|
};
|
|
|
|
const finishQuiz = () => {
|
|
if (timer) clearInterval(timer);
|
|
router.post(route('attempts.finish', props.attempt.id));
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<Head :title="quiz.title" />
|
|
|
|
<div class="min-h-screen bg-slate-900 text-slate-100 flex flex-col font-sans selection:bg-indigo-500/30">
|
|
<!-- Header -->
|
|
<header class="h-20 flex items-center justify-between px-8 border-b border-slate-800 bg-slate-900/80 backdrop-blur sticky top-0 z-10">
|
|
<div class="flex items-center gap-4">
|
|
<div class="w-10 h-10 bg-indigo-600 rounded-lg flex items-center justify-center font-bold text-xl shadow-[0_0_15px_rgba(79,70,229,0.4)]">
|
|
Q
|
|
</div>
|
|
<div>
|
|
<h1 class="font-bold text-lg leading-none">{{ quiz.title }}</h1>
|
|
<p class="text-slate-500 text-xs mt-1 uppercase tracking-widest font-semibold">{{ currentQuestionIndex + 1 }} sur {{ quiz.questions.length }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-6">
|
|
<!-- Progress Circular (CSS only simplification) -->
|
|
<div class="hidden sm:flex items-center gap-2">
|
|
<span class="text-xs text-slate-500 font-bold uppercase tracking-wider">Progression</span>
|
|
<div class="w-32 h-2 bg-slate-800 rounded-full overflow-hidden">
|
|
<div class="h-full bg-indigo-500 transition-all duration-500" :style="{ width: progress + '%' }"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 bg-slate-800/50 px-4 py-2 rounded-xl border border-slate-700 shadow-inner">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<span class="font-mono text-xl font-bold" :class="{ 'text-red-400 animate-pulse': timeLeft < 300 }">
|
|
{{ formatTime(timeLeft) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Main Content -->
|
|
<main class="flex-1 flex flex-col items-center justify-center p-8">
|
|
<div class="w-full max-w-3xl">
|
|
<!-- Question Card -->
|
|
<div class="bg-slate-800/50 backdrop-blur-xl rounded-3xl p-8 sm:p-12 border border-slate-700 shadow-2xl relative overflow-hidden group">
|
|
<!-- Subtle background glow -->
|
|
<div class="absolute -top-24 -right-24 w-64 h-64 bg-indigo-600/10 blur-[100px] rounded-full group-hover:bg-indigo-600/20 transition-all duration-700"></div>
|
|
|
|
<div class="relative z-10">
|
|
<div class="mb-8">
|
|
<div v-if="currentQuestion.title" class="px-3 py-1 bg-indigo-500/20 text-indigo-400 border border-indigo-500/30 rounded-lg text-xs font-black uppercase tracking-widest inline-block mb-4">
|
|
{{ currentQuestion.title }}
|
|
</div>
|
|
|
|
<h2 class="text-2xl sm:text-3xl font-bold leading-tight">
|
|
{{ currentQuestion.label }}
|
|
</h2>
|
|
|
|
<div v-if="currentQuestion.context" class="mt-6 p-6 bg-slate-700/20 border-l-4 border-indigo-500 rounded-r-2xl text-slate-300 leading-relaxed italic">
|
|
{{ currentQuestion.context }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- QCM Options -->
|
|
<div v-if="currentQuestion.type === 'qcm'" class="grid gap-4">
|
|
<button
|
|
v-for="option in currentQuestion.options"
|
|
:key="option.id"
|
|
@click="selectOption(option.id)"
|
|
class="w-full text-left p-6 rounded-2xl border transition-all duration-200 flex items-center justify-between group/opt"
|
|
:class="[
|
|
answers[currentQuestion.id]?.option_id === option.id
|
|
? 'bg-indigo-600 border-indigo-400 shadow-lg shadow-indigo-600/20 translate-x-1'
|
|
: 'bg-slate-700/30 border-slate-600 hover:border-slate-400 hover:bg-slate-700/50'
|
|
]"
|
|
>
|
|
<span class="font-medium text-lg">{{ option.option_text }}</span>
|
|
<div
|
|
class="w-6 h-6 rounded-full border-2 flex items-center justify-center transition-colors"
|
|
:class="[
|
|
answers[currentQuestion.id]?.option_id === option.id
|
|
? 'border-white bg-white'
|
|
: 'border-slate-500 group-hover/opt:border-slate-300'
|
|
]"
|
|
>
|
|
<div v-if="answers[currentQuestion.id]?.option_id === option.id" class="w-2.5 h-2.5 bg-indigo-600 rounded-full"></div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Open Question -->
|
|
<div v-else>
|
|
<textarea
|
|
class="w-full h-48 bg-slate-700/30 border border-slate-600 rounded-2xl p-6 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all outline-none resize-none text-lg"
|
|
placeholder="Saisissez votre réponse ici..."
|
|
v-model="answers[currentQuestion.id].text_content"
|
|
@blur="saveAnswer"
|
|
></textarea>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Navigation Controls -->
|
|
<div class="mt-12 flex justify-between items-center px-4">
|
|
<button
|
|
@click="prevQuestion"
|
|
:disabled="currentQuestionIndex === 0"
|
|
class="flex items-center gap-2 font-bold uppercase tracking-widest text-sm text-slate-400 hover:text-white transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
Précédent
|
|
</button>
|
|
|
|
<div class="flex items-center gap-1">
|
|
<div
|
|
v-for="(_, i) in quiz.questions"
|
|
:key="i"
|
|
class="w-1.5 h-1.5 rounded-full transition-all duration-300"
|
|
:class="[i === currentQuestionIndex ? 'bg-indigo-500 w-6' : (answers[quiz.questions[i].id] ? 'bg-slate-500' : 'bg-slate-700')]"
|
|
></div>
|
|
</div>
|
|
|
|
<button
|
|
@click="nextQuestion"
|
|
class="group flex items-center gap-3 bg-white text-slate-900 px-8 py-4 rounded-2xl font-bold uppercase tracking-widest text-sm hover:bg-indigo-500 hover:text-white transition-all duration-300 active:scale-95 shadow-xl shadow-white/5"
|
|
>
|
|
{{ currentQuestionIndex === quiz.questions.length - 1 ? 'Terminer' : 'Suivant' }}
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Footer / Feedback -->
|
|
<footer class="h-12 flex items-center px-8 text-[10px] text-slate-600 uppercase tracking-[0.2em] font-bold">
|
|
RecruitQuizz Pro v1.0 • Vos réponses sont sauvegardées automatiquement.
|
|
</footer>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
.animate-pulse {
|
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
}
|
|
</style>
|