@commonpub/layer 0.18.1 → 0.18.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -17,14 +17,22 @@ const activeContest = computed(() => {
|
|
|
17
17
|
// (they're async on first load). A local ref would reset on remount and
|
|
18
18
|
// the user would see the banner "come back" after dismissing — which also
|
|
19
19
|
// fails the navigation.spec.ts e2e test.
|
|
20
|
+
//
|
|
21
|
+
// NOTE: useState returns a Ref. Vue SFC's auto-unwrap-on-template-write
|
|
22
|
+
// is reliable for bindings the compiler recognizes as refs, but for
|
|
23
|
+
// auto-imported Nuxt composables the detection is inconsistent across
|
|
24
|
+
// versions — safer to set `.value` explicitly from a real handler.
|
|
20
25
|
const heroDismissed = useState('cpub:hero-dismissed', () => false);
|
|
26
|
+
function dismissHero(): void {
|
|
27
|
+
heroDismissed.value = true;
|
|
28
|
+
}
|
|
21
29
|
</script>
|
|
22
30
|
|
|
23
31
|
<template>
|
|
24
32
|
<section v-if="!heroDismissed" class="cpub-hero-banner">
|
|
25
33
|
<div class="cpub-hero-grid-bg" />
|
|
26
34
|
<div class="cpub-hero-gradient" />
|
|
27
|
-
<button class="cpub-hero-dismiss" title="Dismiss" @click="
|
|
35
|
+
<button class="cpub-hero-dismiss" title="Dismiss" @click="dismissHero">
|
|
28
36
|
<i class="fa-solid fa-xmark"></i>
|
|
29
37
|
</button>
|
|
30
38
|
<div class="cpub-hero-inner">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"@aws-sdk/client-s3": "^3.1010.0",
|
|
31
31
|
"@commonpub/explainer": "^0.7.12",
|
|
32
32
|
"@commonpub/schema": "^0.14.4",
|
|
33
|
-
"@commonpub/server": "^2.47.
|
|
33
|
+
"@commonpub/server": "^2.47.3",
|
|
34
34
|
"@tiptap/core": "^2.11.0",
|
|
35
35
|
"@tiptap/extension-bold": "^2.11.0",
|
|
36
36
|
"@tiptap/extension-bullet-list": "^2.11.0",
|
|
@@ -53,13 +53,13 @@
|
|
|
53
53
|
"vue": "^3.4.0",
|
|
54
54
|
"vue-router": "^4.3.0",
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
|
-
"@commonpub/docs": "0.6.2",
|
|
57
56
|
"@commonpub/auth": "0.5.1",
|
|
57
|
+
"@commonpub/docs": "0.6.2",
|
|
58
58
|
"@commonpub/config": "0.11.0",
|
|
59
|
+
"@commonpub/learning": "0.5.2",
|
|
59
60
|
"@commonpub/editor": "0.7.9",
|
|
60
|
-
"@commonpub/
|
|
61
|
-
"@commonpub/protocol": "0.9.9"
|
|
62
|
-
"@commonpub/ui": "0.8.5"
|
|
61
|
+
"@commonpub/ui": "0.8.5",
|
|
62
|
+
"@commonpub/protocol": "0.9.9"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@testing-library/jest-dom": "^6.9.1",
|
package/pages/index.vue
CHANGED
|
@@ -76,7 +76,12 @@ const { data: contests, pending: contestsPending } = await useFetch<{ items: Con
|
|
|
76
76
|
|
|
77
77
|
// Shared with HeroSection.vue via the same useState key so the dismiss
|
|
78
78
|
// persists across the configurable-renderer and legacy code paths.
|
|
79
|
+
// Use an explicit handler — see HeroSection.vue for why template
|
|
80
|
+
// auto-unwrap-on-write isn't reliable for Nuxt composables.
|
|
79
81
|
const heroDismissed = useState('cpub:hero-dismissed', () => false);
|
|
82
|
+
function dismissHero(): void {
|
|
83
|
+
heroDismissed.value = true;
|
|
84
|
+
}
|
|
80
85
|
const joinedHubs = ref(new Set<string>());
|
|
81
86
|
|
|
82
87
|
// Active contest for hero banner
|
|
@@ -166,7 +171,7 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
|
|
|
166
171
|
<section v-if="!heroDismissed" class="cpub-hero-banner">
|
|
167
172
|
<div class="cpub-hero-grid-bg" />
|
|
168
173
|
<div class="cpub-hero-gradient" />
|
|
169
|
-
<button class="cpub-hero-dismiss" title="Dismiss" @click="
|
|
174
|
+
<button class="cpub-hero-dismiss" title="Dismiss" @click="dismissHero">
|
|
170
175
|
<i class="fa-solid fa-xmark"></i>
|
|
171
176
|
</button>
|
|
172
177
|
<div class="cpub-hero-inner">
|
|
@@ -37,14 +37,25 @@ const editMarkdown = ref('');
|
|
|
37
37
|
// Video URL
|
|
38
38
|
const editVideoUrl = ref('');
|
|
39
39
|
|
|
40
|
-
// Quiz data
|
|
40
|
+
// Quiz data — canonical shape: {id, options:[{id,text}], correctOptionId}.
|
|
41
|
+
// Server grades via `@commonpub/learning/gradeQuiz`; see session 129.
|
|
42
|
+
interface QuizOption {
|
|
43
|
+
id: string;
|
|
44
|
+
text: string;
|
|
45
|
+
}
|
|
41
46
|
interface QuizQuestion {
|
|
47
|
+
id: string;
|
|
42
48
|
question: string;
|
|
43
|
-
options:
|
|
44
|
-
|
|
49
|
+
options: QuizOption[];
|
|
50
|
+
correctOptionId: string;
|
|
45
51
|
explanation: string;
|
|
46
52
|
}
|
|
47
53
|
const editQuiz = ref<QuizQuestion[]>([]);
|
|
54
|
+
const editPassingScore = ref(70);
|
|
55
|
+
|
|
56
|
+
function genId(): string {
|
|
57
|
+
return crypto.randomUUID().replace(/-/g, '').slice(0, 8);
|
|
58
|
+
}
|
|
48
59
|
|
|
49
60
|
const lessonTypes = ['article', 'video', 'quiz', 'project', 'explainer'] as const;
|
|
50
61
|
|
|
@@ -69,12 +80,46 @@ watch(lesson, (l) => {
|
|
|
69
80
|
editVideoUrl.value = (content.videoUrl as string) ?? '';
|
|
70
81
|
editMarkdown.value = (content.notes as string) ?? '';
|
|
71
82
|
} else if (l.type === 'quiz') {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
83
|
+
editPassingScore.value = typeof content.passingScore === 'number' ? content.passingScore : 70;
|
|
84
|
+
const rawQuestions = Array.isArray(content.questions) ? (content.questions as unknown[]) : [];
|
|
85
|
+
editQuiz.value = rawQuestions.map((raw) => migrateQuestion(raw));
|
|
75
86
|
}
|
|
76
87
|
}, { immediate: true });
|
|
77
88
|
|
|
89
|
+
// Upgrade legacy `{question, options: string[], correctIndex, explanation}` to the
|
|
90
|
+
// canonical `{id, options:[{id,text}], correctOptionId, explanation}` shape.
|
|
91
|
+
// Content already in canonical shape is preserved. Missing ids are filled in.
|
|
92
|
+
function migrateQuestion(raw: unknown): QuizQuestion {
|
|
93
|
+
const src = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;
|
|
94
|
+
const options: QuizOption[] = Array.isArray(src.options)
|
|
95
|
+
? (src.options as unknown[]).map((o) => {
|
|
96
|
+
if (typeof o === 'string') return { id: genId(), text: o };
|
|
97
|
+
const obj = (o && typeof o === 'object' ? o : {}) as Record<string, unknown>;
|
|
98
|
+
return {
|
|
99
|
+
id: typeof obj.id === 'string' && obj.id ? obj.id : genId(),
|
|
100
|
+
text: typeof obj.text === 'string' ? obj.text : '',
|
|
101
|
+
};
|
|
102
|
+
})
|
|
103
|
+
: [];
|
|
104
|
+
|
|
105
|
+
let correctOptionId =
|
|
106
|
+
typeof src.correctOptionId === 'string' && src.correctOptionId ? src.correctOptionId : '';
|
|
107
|
+
if (!correctOptionId && typeof src.correctIndex === 'number') {
|
|
108
|
+
correctOptionId = options[src.correctIndex]?.id ?? options[0]?.id ?? '';
|
|
109
|
+
}
|
|
110
|
+
if (!correctOptionId) {
|
|
111
|
+
correctOptionId = options[0]?.id ?? '';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
id: typeof src.id === 'string' && src.id ? src.id : genId(),
|
|
116
|
+
question: typeof src.question === 'string' ? src.question : '',
|
|
117
|
+
options,
|
|
118
|
+
correctOptionId,
|
|
119
|
+
explanation: typeof src.explanation === 'string' ? src.explanation : '',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
78
123
|
// Build content payload based on type
|
|
79
124
|
function buildContent(): unknown {
|
|
80
125
|
switch (editType.value) {
|
|
@@ -85,7 +130,17 @@ function buildContent(): unknown {
|
|
|
85
130
|
case 'video':
|
|
86
131
|
return { videoUrl: editVideoUrl.value, notes: editMarkdown.value };
|
|
87
132
|
case 'quiz':
|
|
88
|
-
return {
|
|
133
|
+
return {
|
|
134
|
+
type: 'quiz',
|
|
135
|
+
passingScore: editPassingScore.value,
|
|
136
|
+
questions: editQuiz.value.map((q) => ({
|
|
137
|
+
id: q.id,
|
|
138
|
+
question: q.question,
|
|
139
|
+
options: q.options.map((o) => ({ id: o.id, text: o.text })),
|
|
140
|
+
correctOptionId: q.correctOptionId,
|
|
141
|
+
...(q.explanation ? { explanation: q.explanation } : {}),
|
|
142
|
+
})),
|
|
143
|
+
};
|
|
89
144
|
default:
|
|
90
145
|
return { markdown: editMarkdown.value };
|
|
91
146
|
}
|
|
@@ -134,10 +189,17 @@ async function unlinkContent(): Promise<void> {
|
|
|
134
189
|
|
|
135
190
|
// Quiz helpers
|
|
136
191
|
function addQuestion(): void {
|
|
192
|
+
const options: QuizOption[] = [
|
|
193
|
+
{ id: genId(), text: '' },
|
|
194
|
+
{ id: genId(), text: '' },
|
|
195
|
+
{ id: genId(), text: '' },
|
|
196
|
+
{ id: genId(), text: '' },
|
|
197
|
+
];
|
|
137
198
|
editQuiz.value.push({
|
|
199
|
+
id: genId(),
|
|
138
200
|
question: '',
|
|
139
|
-
options
|
|
140
|
-
|
|
201
|
+
options,
|
|
202
|
+
correctOptionId: options[0]!.id,
|
|
141
203
|
explanation: '',
|
|
142
204
|
});
|
|
143
205
|
}
|
|
@@ -147,14 +209,16 @@ function removeQuestion(index: number): void {
|
|
|
147
209
|
}
|
|
148
210
|
|
|
149
211
|
function addOption(qIndex: number): void {
|
|
150
|
-
editQuiz.value[qIndex]!.options.push('');
|
|
212
|
+
editQuiz.value[qIndex]!.options.push({ id: genId(), text: '' });
|
|
151
213
|
}
|
|
152
214
|
|
|
153
215
|
function removeOption(qIndex: number, oIndex: number): void {
|
|
154
216
|
const q = editQuiz.value[qIndex]!;
|
|
217
|
+
const removed = q.options[oIndex];
|
|
155
218
|
q.options.splice(oIndex, 1);
|
|
156
|
-
|
|
157
|
-
|
|
219
|
+
// If the removed option was the correct answer, reassign to the first remaining.
|
|
220
|
+
if (removed && removed.id === q.correctOptionId) {
|
|
221
|
+
q.correctOptionId = q.options[0]?.id ?? '';
|
|
158
222
|
}
|
|
159
223
|
}
|
|
160
224
|
|
|
@@ -265,7 +329,19 @@ const videoEmbedUrl = computed(() => {
|
|
|
265
329
|
<section v-else-if="editType === 'quiz'" class="lesson-section">
|
|
266
330
|
<h2 class="lesson-section-title">Quiz Questions</h2>
|
|
267
331
|
|
|
268
|
-
<div
|
|
332
|
+
<div class="form-field" style="margin-bottom: 16px; max-width: 240px;">
|
|
333
|
+
<label for="passing-score" class="form-label">Passing Score (%)</label>
|
|
334
|
+
<input
|
|
335
|
+
id="passing-score"
|
|
336
|
+
v-model.number="editPassingScore"
|
|
337
|
+
type="number"
|
|
338
|
+
min="0"
|
|
339
|
+
max="100"
|
|
340
|
+
class="form-input"
|
|
341
|
+
/>
|
|
342
|
+
</div>
|
|
343
|
+
|
|
344
|
+
<div v-for="(q, qi) in editQuiz" :key="q.id" class="quiz-question-card">
|
|
269
345
|
<div class="quiz-q-header">
|
|
270
346
|
<span class="quiz-q-number">Q{{ qi + 1 }}</span>
|
|
271
347
|
<button class="cpub-delete-btn" @click="removeQuestion(qi)" aria-label="Remove question">
|
|
@@ -279,11 +355,11 @@ const videoEmbedUrl = computed(() => {
|
|
|
279
355
|
</div>
|
|
280
356
|
|
|
281
357
|
<div class="quiz-options">
|
|
282
|
-
<div v-for="(opt, oi) in q.options" :key="
|
|
358
|
+
<div v-for="(opt, oi) in q.options" :key="opt.id" class="quiz-option-row">
|
|
283
359
|
<label class="quiz-radio-label">
|
|
284
|
-
<input type="radio" :name="`q
|
|
360
|
+
<input type="radio" :name="`q-${q.id}-correct`" :value="opt.id" v-model="q.correctOptionId" />
|
|
285
361
|
</label>
|
|
286
|
-
<input v-model="
|
|
362
|
+
<input v-model="opt.text" type="text" class="form-input quiz-option-input" :placeholder="`Option ${oi + 1}`" />
|
|
287
363
|
<button v-if="q.options.length > 2" class="cpub-delete-btn cpub-delete-btn-sm" @click="removeOption(qi, oi)" aria-label="Remove option">
|
|
288
364
|
<i class="fa-solid fa-xmark"></i>
|
|
289
365
|
</button>
|
|
@@ -49,6 +49,27 @@ async function markComplete(): Promise<void> {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// Quiz server-response shape (mirrors @commonpub/learning's QuizGrade).
|
|
53
|
+
interface QuizQuestionResult {
|
|
54
|
+
questionId: string;
|
|
55
|
+
selectedOptionId: string | null;
|
|
56
|
+
correctOptionId: string;
|
|
57
|
+
correct: boolean;
|
|
58
|
+
explanation?: string;
|
|
59
|
+
}
|
|
60
|
+
interface QuizGrade {
|
|
61
|
+
correct: number;
|
|
62
|
+
total: number;
|
|
63
|
+
score: number;
|
|
64
|
+
passed: boolean;
|
|
65
|
+
results: QuizQuestionResult[];
|
|
66
|
+
}
|
|
67
|
+
interface CompleteResponse {
|
|
68
|
+
progress: number;
|
|
69
|
+
certificateIssued: boolean;
|
|
70
|
+
quiz?: QuizGrade;
|
|
71
|
+
}
|
|
72
|
+
|
|
52
73
|
// Build flat lesson list for prev/next navigation
|
|
53
74
|
interface FlatLesson {
|
|
54
75
|
slug: string;
|
|
@@ -89,41 +110,78 @@ const videoUrl = computed(() => {
|
|
|
89
110
|
return url;
|
|
90
111
|
});
|
|
91
112
|
|
|
92
|
-
// Quiz data
|
|
113
|
+
// Quiz data — answers are GRADED SERVER-SIDE. The GET response redacts
|
|
114
|
+
// `correctOptionId` + `explanation` from each question, so the client cannot
|
|
115
|
+
// grade locally; it must POST to /complete and render the server's grade.
|
|
116
|
+
interface QuizOption {
|
|
117
|
+
id: string;
|
|
118
|
+
text: string;
|
|
119
|
+
}
|
|
93
120
|
interface QuizQuestion {
|
|
121
|
+
id: string;
|
|
94
122
|
question: string;
|
|
95
|
-
options:
|
|
96
|
-
|
|
97
|
-
explanation: string;
|
|
123
|
+
options: QuizOption[];
|
|
124
|
+
// correctOptionId and explanation are redacted until after submission.
|
|
98
125
|
}
|
|
99
126
|
|
|
100
127
|
const quizQuestions = computed<QuizQuestion[]>(() => {
|
|
101
128
|
const content = lesson.value?.content as Record<string, unknown> | null;
|
|
102
129
|
if (!content || !Array.isArray(content.questions)) return [];
|
|
103
|
-
return content.questions as QuizQuestion[];
|
|
130
|
+
return (content.questions as QuizQuestion[]).filter((q) => q && Array.isArray(q.options));
|
|
104
131
|
});
|
|
105
132
|
|
|
106
|
-
|
|
107
|
-
|
|
133
|
+
// Draft answers: questionId → selected optionId. User can change freely
|
|
134
|
+
// until submit.
|
|
135
|
+
const quizAnswers = ref<Record<string, string>>({});
|
|
136
|
+
const quizSubmitting = ref(false);
|
|
137
|
+
const quizGrade = ref<QuizGrade | null>(null);
|
|
138
|
+
// Lookup map from questionId → result for the current submission.
|
|
139
|
+
const quizResultByQuestion = computed<Record<string, QuizQuestionResult>>(() => {
|
|
140
|
+
if (!quizGrade.value) return {};
|
|
141
|
+
const map: Record<string, QuizQuestionResult> = {};
|
|
142
|
+
for (const r of quizGrade.value.results) map[r.questionId] = r;
|
|
143
|
+
return map;
|
|
144
|
+
});
|
|
108
145
|
|
|
109
|
-
function
|
|
110
|
-
if (
|
|
111
|
-
quizAnswers.value[
|
|
112
|
-
quizSubmitted.value[qIndex] = true;
|
|
146
|
+
function selectAnswer(questionId: string, optionId: string): void {
|
|
147
|
+
if (quizGrade.value) return; // locked after submission until retry
|
|
148
|
+
quizAnswers.value = { ...quizAnswers.value, [questionId]: optionId };
|
|
113
149
|
}
|
|
114
150
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
151
|
+
const allQuizAnswered = computed(() =>
|
|
152
|
+
quizQuestions.value.length > 0 &&
|
|
153
|
+
quizQuestions.value.every((q) => !!quizAnswers.value[q.id]),
|
|
154
|
+
);
|
|
118
155
|
|
|
119
|
-
|
|
120
|
-
if (!
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
156
|
+
async function submitQuiz(): Promise<void> {
|
|
157
|
+
if (!allQuizAnswered.value || quizSubmitting.value) return;
|
|
158
|
+
quizSubmitting.value = true;
|
|
159
|
+
try {
|
|
160
|
+
const res = await $fetch<CompleteResponse>(
|
|
161
|
+
`/api/learn/${slug.value}/${lessonSlug.value}/complete`,
|
|
162
|
+
{ method: 'POST', body: { answers: { ...quizAnswers.value } } },
|
|
163
|
+
);
|
|
164
|
+
if (res.quiz) {
|
|
165
|
+
quizGrade.value = res.quiz;
|
|
166
|
+
if (res.quiz.passed) {
|
|
167
|
+
completed.value = true;
|
|
168
|
+
toast.success(`Passed — ${res.quiz.score}%`);
|
|
169
|
+
} else {
|
|
170
|
+
toast.error(`Scored ${res.quiz.score}% — below passing. Try again.`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} catch (err: unknown) {
|
|
174
|
+
const msg = err instanceof Error ? err.message : 'Failed to submit quiz';
|
|
175
|
+
toast.error(msg);
|
|
176
|
+
} finally {
|
|
177
|
+
quizSubmitting.value = false;
|
|
124
178
|
}
|
|
125
|
-
|
|
126
|
-
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function retryQuiz(): void {
|
|
182
|
+
quizAnswers.value = {};
|
|
183
|
+
quizGrade.value = null;
|
|
184
|
+
}
|
|
127
185
|
|
|
128
186
|
// Lesson type icon
|
|
129
187
|
function getLessonTypeIcon(type: string): string {
|
|
@@ -233,7 +291,7 @@ const isOwner = computed(() => user.value?.id === path.value?.author?.id);
|
|
|
233
291
|
|
|
234
292
|
<!-- QUIZ lesson -->
|
|
235
293
|
<div v-if="lesson.type === 'quiz' && quizQuestions.length" class="lesson-quiz">
|
|
236
|
-
<div v-for="(q, qi) in quizQuestions" :key="
|
|
294
|
+
<div v-for="(q, qi) in quizQuestions" :key="q.id" class="quiz-card">
|
|
237
295
|
<div class="quiz-header">
|
|
238
296
|
<span class="quiz-badge">QUESTION {{ qi + 1 }}</span>
|
|
239
297
|
</div>
|
|
@@ -241,30 +299,51 @@ const isOwner = computed(() => user.value?.id === path.value?.author?.id);
|
|
|
241
299
|
<div class="quiz-options">
|
|
242
300
|
<button
|
|
243
301
|
v-for="(opt, oi) in q.options"
|
|
244
|
-
:key="
|
|
302
|
+
:key="opt.id"
|
|
245
303
|
class="quiz-option"
|
|
246
304
|
:class="{
|
|
247
|
-
'selected-correct':
|
|
248
|
-
'selected-wrong':
|
|
249
|
-
|
|
305
|
+
'selected-correct': quizResultByQuestion[q.id] && opt.id === quizResultByQuestion[q.id].correctOptionId,
|
|
306
|
+
'selected-wrong': quizResultByQuestion[q.id] && quizAnswers[q.id] === opt.id && opt.id !== quizResultByQuestion[q.id].correctOptionId,
|
|
307
|
+
'selected-pending': !quizGrade && quizAnswers[q.id] === opt.id,
|
|
308
|
+
answered: !!quizGrade,
|
|
250
309
|
}"
|
|
251
|
-
:disabled="!!
|
|
252
|
-
|
|
310
|
+
:disabled="!!quizGrade"
|
|
311
|
+
:aria-pressed="quizAnswers[q.id] === opt.id"
|
|
312
|
+
@click="selectAnswer(q.id, opt.id)"
|
|
253
313
|
>
|
|
254
314
|
<span class="quiz-option-key">{{ String.fromCharCode(65 + oi) }}</span>
|
|
255
|
-
<span class="quiz-option-text">{{ opt }}</span>
|
|
256
|
-
<span v-if="
|
|
257
|
-
<span v-if="
|
|
315
|
+
<span class="quiz-option-text">{{ opt.text }}</span>
|
|
316
|
+
<span v-if="quizResultByQuestion[q.id] && opt.id === quizResultByQuestion[q.id].correctOptionId" class="quiz-option-indicator"><i class="fa-solid fa-check"></i></span>
|
|
317
|
+
<span v-if="quizResultByQuestion[q.id] && quizAnswers[q.id] === opt.id && opt.id !== quizResultByQuestion[q.id].correctOptionId" class="quiz-option-indicator"><i class="fa-solid fa-xmark"></i></span>
|
|
258
318
|
</button>
|
|
259
319
|
</div>
|
|
260
|
-
<div v-if="
|
|
261
|
-
<i class="fa-solid fa-lightbulb"></i> {{ q.explanation }}
|
|
320
|
+
<div v-if="quizResultByQuestion[q.id]?.explanation" class="quiz-explanation">
|
|
321
|
+
<i class="fa-solid fa-lightbulb"></i> {{ quizResultByQuestion[q.id]!.explanation }}
|
|
262
322
|
</div>
|
|
263
323
|
</div>
|
|
264
324
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
<
|
|
325
|
+
<!-- Submit / result / retry -->
|
|
326
|
+
<div class="quiz-actions">
|
|
327
|
+
<button
|
|
328
|
+
v-if="!quizGrade"
|
|
329
|
+
class="quiz-submit-btn"
|
|
330
|
+
:disabled="!allQuizAnswered || quizSubmitting || !isAuthenticated"
|
|
331
|
+
@click="submitQuiz"
|
|
332
|
+
>
|
|
333
|
+
<i class="fa-solid fa-check-double"></i>
|
|
334
|
+
{{ quizSubmitting ? 'Grading...' : 'Submit Quiz' }}
|
|
335
|
+
</button>
|
|
336
|
+
<p v-if="!isAuthenticated" class="quiz-signin-hint">Sign in to submit this quiz.</p>
|
|
337
|
+
</div>
|
|
338
|
+
|
|
339
|
+
<div v-if="quizGrade" class="quiz-score" :class="{ passed: quizGrade.passed, failed: !quizGrade.passed }">
|
|
340
|
+
<div class="quiz-score-value">{{ quizGrade.correct }} / {{ quizGrade.total }}</div>
|
|
341
|
+
<div class="quiz-score-label">
|
|
342
|
+
{{ quizGrade.score }}% — {{ quizGrade.passed ? 'Passed' : 'Did not pass' }}
|
|
343
|
+
</div>
|
|
344
|
+
<button v-if="!quizGrade.passed" class="quiz-retry-btn" @click="retryQuiz">
|
|
345
|
+
<i class="fa-solid fa-rotate-right"></i> Try Again
|
|
346
|
+
</button>
|
|
268
347
|
</div>
|
|
269
348
|
</div>
|
|
270
349
|
|
|
@@ -279,7 +358,7 @@ const isOwner = computed(() => user.value?.id === path.value?.author?.id);
|
|
|
279
358
|
|
|
280
359
|
<!-- Footer: Mark Complete + Prev/Next -->
|
|
281
360
|
<footer class="lesson-footer">
|
|
282
|
-
<div v-if="isAuthenticated" class="lesson-complete-row">
|
|
361
|
+
<div v-if="isAuthenticated && lesson.type !== 'quiz'" class="lesson-complete-row">
|
|
283
362
|
<button v-if="!completed" class="lesson-complete-btn" @click="markComplete" :disabled="completing">
|
|
284
363
|
<i class="fa-solid fa-check-circle"></i> {{ completing ? 'Marking...' : 'Mark as Complete' }}
|
|
285
364
|
</button>
|
|
@@ -287,6 +366,11 @@ const isOwner = computed(() => user.value?.id === path.value?.author?.id);
|
|
|
287
366
|
<i class="fa-solid fa-check-circle"></i> Completed
|
|
288
367
|
</div>
|
|
289
368
|
</div>
|
|
369
|
+
<div v-else-if="isAuthenticated && completed" class="lesson-complete-row">
|
|
370
|
+
<div class="lesson-completed-badge">
|
|
371
|
+
<i class="fa-solid fa-check-circle"></i> Completed
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
290
374
|
|
|
291
375
|
<div class="lesson-nav-footer">
|
|
292
376
|
<NuxtLink v-if="prevLesson" :to="`/learn/${slug}/${prevLesson.slug}`" class="lesson-nav-btn lesson-nav-prev">
|
|
@@ -396,10 +480,24 @@ const isOwner = computed(() => user.value?.id === path.value?.author?.id);
|
|
|
396
480
|
.quiz-option-indicator { font-size: 12px; flex-shrink: 0; }
|
|
397
481
|
.quiz-option.selected-correct .quiz-option-indicator { color: var(--green); }
|
|
398
482
|
.quiz-option.selected-wrong .quiz-option-indicator { color: var(--red); }
|
|
483
|
+
.quiz-option.selected-pending { background: var(--accent-bg); border-color: var(--accent); }
|
|
484
|
+
.quiz-option.selected-pending .quiz-option-key { color: var(--accent); }
|
|
485
|
+
.quiz-option.selected-pending .quiz-option-text { color: var(--text); }
|
|
399
486
|
.quiz-explanation { margin-top: 12px; padding: 10px 14px; background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); color: var(--accent); font-size: 13px; display: flex; align-items: flex-start; gap: 8px; line-height: 1.5; }
|
|
487
|
+
.quiz-actions { margin: 16px 0; display: flex; flex-direction: column; align-items: flex-start; gap: 8px; }
|
|
488
|
+
.quiz-submit-btn { padding: 10px 20px; background: var(--accent); color: var(--color-text-inverse); border: var(--border-width-default) solid var(--border); font-size: 13px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; gap: 8px; box-shadow: var(--shadow-md); font-family: inherit; }
|
|
489
|
+
.quiz-submit-btn:hover:not(:disabled) { box-shadow: var(--shadow-sm); transform: translate(1px, 1px); }
|
|
490
|
+
.quiz-submit-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
491
|
+
.quiz-signin-hint { font-size: 12px; color: var(--text-faint); font-family: var(--font-mono); }
|
|
400
492
|
.quiz-score { text-align: center; padding: 20px; border: var(--border-width-default) solid var(--green); background: var(--green-bg); }
|
|
493
|
+
.quiz-score.passed { border-color: var(--green); background: var(--green-bg); }
|
|
494
|
+
.quiz-score.passed .quiz-score-value, .quiz-score.passed .quiz-score-label { color: var(--green); }
|
|
495
|
+
.quiz-score.failed { border-color: var(--red); background: var(--red-bg); }
|
|
496
|
+
.quiz-score.failed .quiz-score-value, .quiz-score.failed .quiz-score-label { color: var(--red); }
|
|
401
497
|
.quiz-score-value { font-size: 28px; font-weight: 700; font-family: var(--font-mono); color: var(--green); }
|
|
402
498
|
.quiz-score-label { font-size: 11px; font-family: var(--font-mono); color: var(--green); text-transform: uppercase; letter-spacing: 0.08em; }
|
|
499
|
+
.quiz-retry-btn { margin-top: 12px; padding: 8px 16px; background: var(--surface); color: var(--text); border: var(--border-width-default) solid var(--border); font-size: 12px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; font-family: inherit; }
|
|
500
|
+
.quiz-retry-btn:hover { background: var(--surface2); }
|
|
403
501
|
|
|
404
502
|
/* Empty state */
|
|
405
503
|
.lesson-empty { color: var(--text-faint); font-size: 13px; text-align: center; padding: 48px 0; display: flex; flex-direction: column; align-items: center; gap: 8px; }
|