@commonpub/layer 0.18.0 → 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.
- package/components/homepage/HeroSection.vue +16 -2
- package/package.json +6 -6
- package/pages/docs/[siteSlug]/[...pagePath].vue +9 -3
- package/pages/docs/[siteSlug]/index.vue +11 -3
- package/pages/index.vue +9 -2
- package/pages/learn/[slug]/[lessonSlug]/edit.vue +92 -16
- package/pages/learn/[slug]/[lessonSlug]/index.vue +135 -37
- package/server/middleware/security.ts +8 -2
- package/utils/highlightSnippet.ts +29 -0
|
@@ -11,14 +11,28 @@ const activeContest = computed(() => {
|
|
|
11
11
|
return items?.find((c) => c.status === 'active') ?? null;
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// Shared via useState so the dismiss sticks across component remounts.
|
|
15
|
+
// HomepageSectionRenderer's v-if wrappers can remount HeroSection when the
|
|
16
|
+
// `sections` useFetch revalidates on hydration or when feature flags flip
|
|
17
|
+
// (they're async on first load). A local ref would reset on remount and
|
|
18
|
+
// the user would see the banner "come back" after dismissing — which also
|
|
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.
|
|
25
|
+
const heroDismissed = useState('cpub:hero-dismissed', () => false);
|
|
26
|
+
function dismissHero(): void {
|
|
27
|
+
heroDismissed.value = true;
|
|
28
|
+
}
|
|
15
29
|
</script>
|
|
16
30
|
|
|
17
31
|
<template>
|
|
18
32
|
<section v-if="!heroDismissed" class="cpub-hero-banner">
|
|
19
33
|
<div class="cpub-hero-grid-bg" />
|
|
20
34
|
<div class="cpub-hero-gradient" />
|
|
21
|
-
<button class="cpub-hero-dismiss" title="Dismiss" @click="
|
|
35
|
+
<button class="cpub-hero-dismiss" title="Dismiss" @click="dismissHero">
|
|
22
36
|
<i class="fa-solid fa-xmark"></i>
|
|
23
37
|
</button>
|
|
24
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": [
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@aws-sdk/client-s3": "^3.1010.0",
|
|
31
31
|
"@commonpub/explainer": "^0.7.12",
|
|
32
|
-
"@commonpub/schema": "^0.14.
|
|
33
|
-
"@commonpub/server": "^2.47.
|
|
32
|
+
"@commonpub/schema": "^0.14.4",
|
|
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",
|
|
@@ -56,10 +56,10 @@
|
|
|
56
56
|
"@commonpub/auth": "0.5.1",
|
|
57
57
|
"@commonpub/docs": "0.6.2",
|
|
58
58
|
"@commonpub/config": "0.11.0",
|
|
59
|
-
"@commonpub/learning": "0.5.
|
|
59
|
+
"@commonpub/learning": "0.5.2",
|
|
60
60
|
"@commonpub/editor": "0.7.9",
|
|
61
|
-
"@commonpub/
|
|
62
|
-
"@commonpub/
|
|
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",
|
|
@@ -254,13 +254,15 @@ useSeoMeta({
|
|
|
254
254
|
</div>
|
|
255
255
|
<div v-if="searchOpen && searchResults?.length" class="docs-search-results">
|
|
256
256
|
<NuxtLink
|
|
257
|
-
v-for="r in (searchResults as Array<{ id: string; title: string; slug: string }>)"
|
|
257
|
+
v-for="r in (searchResults as Array<{ id: string; title: string; slug: string; snippet?: string | null }>)"
|
|
258
258
|
:key="r.id"
|
|
259
259
|
:to="`/docs/${siteSlug}/${r.slug}`"
|
|
260
260
|
class="docs-search-result"
|
|
261
261
|
@click="searchOpen = false; searchQuery = ''"
|
|
262
262
|
>
|
|
263
|
-
{{ r.title }}
|
|
263
|
+
<span class="docs-search-result-title">{{ r.title }}</span>
|
|
264
|
+
<!-- eslint-disable-next-line vue/no-v-html — see highlightSnippet docstring. -->
|
|
265
|
+
<span v-if="r.snippet" class="docs-search-result-snippet" v-html="highlightSnippet(r.snippet)" />
|
|
264
266
|
</NuxtLink>
|
|
265
267
|
</div>
|
|
266
268
|
</div>
|
|
@@ -481,7 +483,7 @@ useSeoMeta({
|
|
|
481
483
|
border: var(--border-width-default) solid var(--border);
|
|
482
484
|
box-shadow: var(--shadow-md);
|
|
483
485
|
z-index: 50;
|
|
484
|
-
max-height:
|
|
486
|
+
max-height: 280px;
|
|
485
487
|
overflow-y: auto;
|
|
486
488
|
}
|
|
487
489
|
|
|
@@ -496,6 +498,10 @@ useSeoMeta({
|
|
|
496
498
|
|
|
497
499
|
.docs-search-result:last-child { border-bottom: none; }
|
|
498
500
|
.docs-search-result:hover { background: var(--surface2); color: var(--accent); }
|
|
501
|
+
.docs-search-result-title { display: block; color: var(--text); font-weight: 500; }
|
|
502
|
+
.docs-search-result-snippet { display: block; margin-top: 2px; color: var(--text-faint); font-size: 11px; line-height: 1.4; }
|
|
503
|
+
.docs-search-result-snippet :deep(b) { background: var(--accent-soft, rgba(91, 156, 246, 0.18)); color: var(--text); font-weight: 600; padding: 0 2px; border-radius: 2px; }
|
|
504
|
+
.docs-search-result:hover .docs-search-result-title { color: var(--accent); }
|
|
499
505
|
|
|
500
506
|
/* Nav Tree */
|
|
501
507
|
.docs-nav { padding: 0; }
|
|
@@ -147,13 +147,17 @@ useSeoMeta({
|
|
|
147
147
|
</div>
|
|
148
148
|
<div v-if="searchOpen && searchResults?.length" class="docs-search-results">
|
|
149
149
|
<NuxtLink
|
|
150
|
-
v-for="r in (searchResults as Array<{ id: string; title: string; slug: string }>)"
|
|
150
|
+
v-for="r in (searchResults as Array<{ id: string; title: string; slug: string; snippet?: string | null }>)"
|
|
151
151
|
:key="r.id"
|
|
152
152
|
:to="`/docs/${siteSlug}/${r.slug}`"
|
|
153
153
|
class="docs-search-result"
|
|
154
154
|
@click="searchOpen = false; searchQuery = ''"
|
|
155
155
|
>
|
|
156
|
-
{{ r.title }}
|
|
156
|
+
<span class="docs-search-result-title">{{ r.title }}</span>
|
|
157
|
+
<!-- eslint-disable-next-line vue/no-v-html — snippet is ts_headline
|
|
158
|
+
output, sanitized by highlightSnippet (escapes everything,
|
|
159
|
+
restores only <b> and </b>). -->
|
|
160
|
+
<span v-if="r.snippet" class="docs-search-result-snippet" v-html="highlightSnippet(r.snippet)" />
|
|
157
161
|
</NuxtLink>
|
|
158
162
|
</div>
|
|
159
163
|
</div>
|
|
@@ -267,10 +271,14 @@ useSeoMeta({
|
|
|
267
271
|
.docs-search-input { width: 100%; padding: 6px 8px 6px 26px; font-size: 12px; border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); }
|
|
268
272
|
.docs-search-input::placeholder { color: var(--text-faint); }
|
|
269
273
|
.docs-search-input:focus { border-color: var(--accent); outline: none; }
|
|
270
|
-
.docs-search-results { position: absolute; top: 100%; left: 16px; right: 16px; background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); z-index: 50; max-height:
|
|
274
|
+
.docs-search-results { position: absolute; top: 100%; left: 16px; right: 16px; background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); z-index: 50; max-height: 280px; overflow-y: auto; }
|
|
271
275
|
.docs-search-result { display: block; padding: 8px 12px; font-size: 12px; color: var(--text-dim); text-decoration: none; border-bottom: var(--border-width-default) solid var(--border2); }
|
|
272
276
|
.docs-search-result:last-child { border-bottom: none; }
|
|
273
277
|
.docs-search-result:hover { background: var(--surface2); color: var(--accent); }
|
|
278
|
+
.docs-search-result-title { display: block; color: var(--text); font-weight: 500; }
|
|
279
|
+
.docs-search-result-snippet { display: block; margin-top: 2px; color: var(--text-faint); font-size: 11px; line-height: 1.4; }
|
|
280
|
+
.docs-search-result-snippet :deep(b) { background: var(--accent-soft, rgba(91, 156, 246, 0.18)); color: var(--text); font-weight: 600; padding: 0 2px; border-radius: 2px; }
|
|
281
|
+
.docs-search-result:hover .docs-search-result-title { color: var(--accent); }
|
|
274
282
|
|
|
275
283
|
.docs-nav { padding: 0; }
|
|
276
284
|
.docs-nav-item { border-bottom: var(--border-width-default) solid var(--border2); }
|
package/pages/index.vue
CHANGED
|
@@ -74,7 +74,14 @@ const { data: contests, pending: contestsPending } = await useFetch<{ items: Con
|
|
|
74
74
|
query: { limit: 3 },
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
// Shared with HeroSection.vue via the same useState key so the dismiss
|
|
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.
|
|
81
|
+
const heroDismissed = useState('cpub:hero-dismissed', () => false);
|
|
82
|
+
function dismissHero(): void {
|
|
83
|
+
heroDismissed.value = true;
|
|
84
|
+
}
|
|
78
85
|
const joinedHubs = ref(new Set<string>());
|
|
79
86
|
|
|
80
87
|
// Active contest for hero banner
|
|
@@ -164,7 +171,7 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
|
|
|
164
171
|
<section v-if="!heroDismissed" class="cpub-hero-banner">
|
|
165
172
|
<div class="cpub-hero-grid-bg" />
|
|
166
173
|
<div class="cpub-hero-gradient" />
|
|
167
|
-
<button class="cpub-hero-dismiss" title="Dismiss" @click="
|
|
174
|
+
<button class="cpub-hero-dismiss" title="Dismiss" @click="dismissHero">
|
|
168
175
|
<i class="fa-solid fa-xmark"></i>
|
|
169
176
|
</button>
|
|
170
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; }
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
// Security middleware — rate limiting + security headers + CSP
|
|
2
|
-
import { checkRateLimit, createRateLimitStore, shouldSkipRateLimit, getSecurityHeaders, buildCspHeader, buildCspDirectives } from '@commonpub/server';
|
|
2
|
+
import { checkRateLimit, createRateLimitStore, createRedisFailOpenLogger, shouldSkipRateLimit, getSecurityHeaders, buildCspHeader, buildCspDirectives } from '@commonpub/server';
|
|
3
3
|
|
|
4
4
|
// Selects a Redis-backed store when NUXT_REDIS_URL is set, otherwise the
|
|
5
5
|
// in-process memory store. Unset env = byte-identical behavior to pre-0.6.
|
|
6
|
-
|
|
6
|
+
// `onRedisError` is rate-limited: first event logs immediately, subsequent
|
|
7
|
+
// events roll up into a one-per-minute summary so a Redis outage doesn't
|
|
8
|
+
// flood the log at real traffic.
|
|
9
|
+
const store = createRateLimitStore({
|
|
10
|
+
redisUrl: process.env.NUXT_REDIS_URL,
|
|
11
|
+
onRedisError: createRedisFailOpenLogger({ scope: 'ratelimit:ip' }),
|
|
12
|
+
});
|
|
7
13
|
const isDev = process.env.NODE_ENV !== 'production';
|
|
8
14
|
|
|
9
15
|
export default defineEventHandler(async (event) => {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a Postgres `ts_headline` snippet safely.
|
|
3
|
+
*
|
|
4
|
+
* `ts_headline` wraps matched tokens in `<b>...</b>` (and nothing else by
|
|
5
|
+
* default). The input text has already been HTML-tag-stripped on the
|
|
6
|
+
* server (see packages/server/src/docs/docs.ts `searchDocsPages` — the
|
|
7
|
+
* `extracted.text_content` CTE uses `regexp_replace` to pull tags out
|
|
8
|
+
* before tokenization). So the only HTML we should ever see in the
|
|
9
|
+
* returned string is the `<b>` markers ts_headline itself emits.
|
|
10
|
+
*
|
|
11
|
+
* To be safe anyway: HTML-escape the whole string, then restore exactly
|
|
12
|
+
* `<b>` and `</b>`. Anything else — including attributes on `<b>` or any
|
|
13
|
+
* other tag that somehow slipped through — becomes harmless escaped text.
|
|
14
|
+
*
|
|
15
|
+
* Return value is intended for `v-html`.
|
|
16
|
+
*/
|
|
17
|
+
export function highlightSnippet(snippet: string | null | undefined): string {
|
|
18
|
+
if (!snippet) return '';
|
|
19
|
+
const escaped = snippet
|
|
20
|
+
.replace(/&/g, '&')
|
|
21
|
+
.replace(/</g, '<')
|
|
22
|
+
.replace(/>/g, '>')
|
|
23
|
+
.replace(/"/g, '"')
|
|
24
|
+
.replace(/'/g, ''');
|
|
25
|
+
// Restore only bare <b> / </b> (no attributes, no whitespace variants).
|
|
26
|
+
return escaped
|
|
27
|
+
.replace(/<b>/g, '<b>')
|
|
28
|
+
.replace(/<\/b>/g, '</b>');
|
|
29
|
+
}
|