Skip to content

Commit d3e7b46

Browse files
authored
feat: Add emoji support for quiz answers with visibility control (#20)
1 parent 4b54bc8 commit d3e7b46

File tree

9 files changed

+83
-45
lines changed

9 files changed

+83
-45
lines changed

app/composables/useQuizSocket.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { useWebSocket, useLocalStorage } from '@vueuse/core'
22
import { createId } from '@paralleldrive/cuid2'
3-
import type { Question, Results } from '~/types'
3+
import type { Question, Results, UserQuestion } from '~/types'
44

55
export const useQuizSocket = () => {
66
const config = useRuntimeConfig()
77

8-
const activeQuestion = ref<Question | null>(null)
8+
const activeQuestion = ref<UserQuestion | Question | null>(null)
99
const selectedAnswer = ref('')
1010
const results = ref<Results | null>(null)
1111
const totalConnections = ref(0)

app/pages/admin.vue

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
<script setup lang="ts">
2-
import type { Question } from '~/types'
2+
import type { Question, AnswerOption } from '~/types'
33
44
definePageMeta({
55
middleware: 'auth'
66
})
77
88
const activeQuestion = ref<Question | null>(null)
99
const allQuestions = ref<Question[]>([])
10-
const newQuestion = ref({
10+
const newQuestion = ref<{
11+
question_text: string
12+
answer_options: AnswerOption[]
13+
}>({
1114
question_text: '',
12-
answer_options: ['', '']
15+
answer_options: [{ text: '' }, { text: '' }]
1316
})
1417
1518
// Load questions
@@ -44,7 +47,12 @@ async function handleLogout() {
4447
async function handleCreateQuestion() {
4548
try {
4649
// Filter out empty options
47-
const filteredOptions = newQuestion.value.answer_options.filter((opt: string) => opt.trim())
50+
const filteredOptions = newQuestion.value.answer_options
51+
.map(opt => ({
52+
text: opt.text.trim(),
53+
emoji: opt.emoji?.trim() || undefined
54+
}))
55+
.filter(opt => opt.text)
4856
4957
if (filteredOptions.length < 2) {
5058
alert('At least 2 answer options required')
@@ -64,7 +72,7 @@ async function handleCreateQuestion() {
6472
// Reset form
6573
newQuestion.value = {
6674
question_text: '',
67-
answer_options: ['', '']
75+
answer_options: [{ text: '' }, { text: '' }]
6876
}
6977
7078
// alert('Question created successfully')
@@ -111,7 +119,7 @@ async function toggleLock() {
111119
112120
// Add option
113121
function addOption() {
114-
newQuestion.value.answer_options.push('')
122+
newQuestion.value.answer_options.push({ text: '' })
115123
}
116124
117125
// Remove option
@@ -136,7 +144,7 @@ function removeOption(index: number) {
136144
<p class="text-lg mb-4 font-bold">{{ activeQuestion.question_text }}</p>
137145
<ul class="list-none p-0 mb-5">
138146
<li v-for="(option, index) in activeQuestion.answer_options" :key="index" class="p-2.5 bg-white border border-black mb-1.5">
139-
{{ option }}
147+
{{ option.text }} <span v-if="option.emoji">{{ option.emoji }}</span>
140148
</li>
141149
</ul>
142150
<div class="flex justify-between items-center">
@@ -173,11 +181,18 @@ function removeOption(index: number) {
173181
<h3 class="mb-2.5 text-lg">Answer Options</h3>
174182
<div v-for="(option, index) in newQuestion.answer_options" :key="index" class="flex gap-2.5 mb-2.5">
175183
<UiInput
176-
v-model="newQuestion.answer_options[index]!"
184+
v-model="option.text"
177185
:placeholder="`Option ${index + 1}`"
178186
required
179187
class="flex-1"
180188
/>
189+
<UiInput
190+
:model-value="option.emoji || ''"
191+
placeholder="Emoji"
192+
class="w-24"
193+
maxlength="10"
194+
@update:model-value="option.emoji = String($event || '').trim() || undefined"
195+
/>
181196
<UiButton
182197
v-if="newQuestion.answer_options.length > 2"
183198
variant="danger"
@@ -208,7 +223,7 @@ function removeOption(index: number) {
208223
<p class="font-bold mb-2.5">{{ question.question_text }}</p>
209224
<ul class="list-disc list-inside p-0 mb-4">
210225
<li v-for="(option, index) in question.answer_options" :key="index">
211-
{{ option }}
226+
{{ option.text }} <span v-if="option.emoji">{{ option.emoji }}</span>
212227
</li>
213228
</ul>
214229
<UiButton @click="publishQuestion(question.id)">

app/pages/index.vue

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import type { Question } from '~/types'
2+
import type { Question, UserQuestion } from '~/types'
33
44
const userNickname = ref('')
55
const nicknameInput = ref('')
@@ -47,7 +47,7 @@ async function changeNickname() {
4747
}
4848
4949
// Fetch active question
50-
const { data: question, refresh: refreshQuestion } = await useFetch<Question>('/api/questions/active', {
50+
const { data: question, refresh: refreshQuestion } = await useFetch<UserQuestion>('/api/questions/active', {
5151
onResponse({ response }) {
5252
const questionData = response._data
5353
if (questionData && !(questionData as any).message) {
@@ -104,6 +104,7 @@ async function submitAnswer() {
104104
}
105105
}
106106
107+
107108
</script>
108109

109110
<template>
@@ -152,21 +153,21 @@ async function submitAnswer() {
152153
:key="index"
153154
class="flex items-center p-5 border-[3px] border-black cursor-pointer transition-all duration-200 relative"
154155
:class="{
155-
'bg-black text-white': selectedAnswer === option,
156+
'bg-black text-white': selectedAnswer === (typeof option === 'string' ? option : option.text),
156157
'opacity-60 cursor-not-allowed': activeQuestion.is_locked,
157158
'hover:translate-x-1 hover:shadow-[-5px_5px_0_#000]': !activeQuestion.is_locked
158159
}"
159160
>
160161
<input
161162
type="radio"
162-
:value="option"
163+
:value="typeof option === 'string' ? option : option.text"
163164
v-model="selectedAnswer"
164165
:disabled="activeQuestion.is_locked"
165166
@change="submitAnswer"
166167
class="w-5 h-5 mr-4"
167-
:class="selectedAnswer === option ? 'accent-white' : 'accent-black'"
168+
:class="selectedAnswer === (typeof option === 'string' ? option : option.text) ? 'accent-white' : 'accent-black'"
168169
/>
169-
<span class="text-lg">{{ option }}</span>
170+
<span class="text-lg">{{ typeof option === 'string' ? option : option.text }}</span>
170171
</label>
171172
</div>
172173

app/pages/results.vue

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ function getBarWidth(count: number) {
6262
return 0
6363
}
6464
65-
const maxVotes = Math.max(...Object.values(results.value.results))
65+
const maxVotes = Math.max(...Object.values(results.value.results).map(r => r.count))
6666
if (maxVotes === 0) {
6767
return 0
6868
}
@@ -109,15 +109,15 @@ function getPercentage(count: number) {
109109
<!-- Results Chart -->
110110
<div class="flex flex-col gap-6">
111111
<div
112-
v-for="(count, option) in results.results"
112+
v-for="(result, option) in results.results"
113113
:key="option"
114114
class="flex flex-col gap-2.5"
115115
>
116116
<div class="flex justify-between items-center text-lg">
117-
<span class="font-bold">{{ option }}</span>
117+
<span class="font-bold">{{ option }} <span v-if="result.emoji && !hideResults" class="ml-2">{{ result.emoji }}</span></span>
118118
<span class="py-1 px-2.5 bg-gray-100 border-2 border-black text-sm">
119119
<template v-if="hideResults">?</template>
120-
<template v-else>{{ count }}</template>
120+
<template v-else>{{ result.count }}</template>
121121
votes
122122
</span>
123123
</div>
@@ -128,9 +128,9 @@ function getPercentage(count: number) {
128128
<div
129129
v-else
130130
class="h-full bg-black transition-width duration-500 ease-out flex items-center justify-end pr-2.5 min-w-[50px] relative result-bar"
131-
:style="{ width: getBarWidth(count) + '%' }"
131+
:style="{ width: getBarWidth(result.count) + '%' }"
132132
>
133-
<span class="text-white font-bold text-xl text-shadow-lg relative z-10">{{ getPercentage(count) }}%</span>
133+
<span class="text-white font-bold text-xl text-shadow-lg relative z-10">{{ getPercentage(result.count) }}%</span>
134134
</div>
135135
</div>
136136
</div>

app/types.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
1+
export interface AnswerOption {
2+
text: string
3+
emoji?: string
4+
}
5+
16
export interface Question {
27
id: string
38
question_text: string
4-
answer_options: string[]
9+
answer_options: AnswerOption[]
510
is_locked: boolean
611
createdAt: string
712
alreadyPublished: boolean
813
}
914

15+
export type UserQuestion = Omit<Question, 'answer_options'> & {
16+
answer_options: string[]
17+
}
18+
1019
export type InputQuestion = Omit<Question, 'id' | 'is_locked' | 'createdAt' | 'alreadyPublished'>
1120

1221
export interface Answer {
@@ -20,7 +29,7 @@ export interface Answer {
2029

2130
export interface Results {
2231
question: Question
23-
results: Record<string, number>
32+
results: Record<string, { count: number, emoji?: string }>
2433
totalVotes: number
2534
totalConnections: number
2635
}

server/api/answers/submit.post.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ export default defineEventHandler(async (event) => {
3232
}
3333

3434
// Normalize and validate answer
35-
const answerOptions = activeQuestion.answer_options.map(opt => opt.toLowerCase())
35+
// Normalize and validate answer
36+
const answerOptions = activeQuestion.answer_options.map(opt => opt.text.toLowerCase())
3637
const selectedAnswerNormalized = selected_answer.toLowerCase()
3738

3839
if (!answerOptions.includes(selectedAnswerNormalized)) {
@@ -43,14 +44,14 @@ export default defineEventHandler(async (event) => {
4344
}
4445

4546
// Find the original-cased answer option
46-
const originalAnswer = activeQuestion.answer_options.find(opt => opt.toLowerCase() === selectedAnswerNormalized)
47+
const originalAnswer = activeQuestion.answer_options.find(opt => opt.text.toLowerCase() === selectedAnswerNormalized)
4748

4849
// Submit answer
4950
await submitAnswer({
5051
question_id: activeQuestion.id,
5152
user_id,
5253
user_nickname,
53-
selected_answer: originalAnswer || selected_answer
54+
selected_answer: originalAnswer ? originalAnswer.text : selected_answer
5455
})
5556

5657
// Schedule bundled results update

server/api/questions/active.get.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
1-
import type { Question } from '~/types'
1+
import type { UserQuestion } from '~/types'
22

3-
export default defineEventHandler(async (): Promise<Question | { message: string }> => {
3+
export default defineEventHandler(async (): Promise<UserQuestion | { message: string }> => {
44
const question = await getActiveQuestion()
5-
return question || { message: 'No active question' }
5+
6+
if (question) {
7+
// Strip emojis from answer options before sending to the client
8+
return {
9+
...question,
10+
answer_options: question.answer_options.map(option => option.text)
11+
}
12+
}
13+
14+
return { message: 'No active question' }
615
})

server/api/questions/create.post.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Question } from '~/types'
1+
import type { Question, AnswerOption } from '~/types'
22

33
export default defineEventHandler(async (event) => {
44
verifyAdmin(event)
@@ -24,8 +24,11 @@ export default defineEventHandler(async (event) => {
2424
}
2525

2626
const answer_options = raw_answer_options
27-
.map(option => typeof option === 'string' ? option.trim() : '')
28-
.filter(option => option)
27+
.map((option: AnswerOption) => ({
28+
text: typeof option.text === 'string' ? option.text.trim() : '',
29+
emoji: typeof option.emoji === 'string' ? option.emoji.trim() : undefined
30+
}))
31+
.filter(option => option.text)
2932

3033
if (answer_options.length < 2) {
3134
throw createError({

server/utils/storage.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ export async function publishQuestion(questionId: string): Promise<Question | un
219219
await fs.writeFile(ANSWERS_FILE, JSON.stringify([]))
220220

221221
// Broadcast the new question as a results update
222-
const results = await getResultsForQuestion(question.id)
222+
const results = await getResultsForQuestion(question.id, questions)
223223
broadcast('results-update', results)
224224
}
225225
return question
@@ -285,7 +285,7 @@ export async function submitAnswer(answerData: Omit<Answer, 'id' | 'timestamp'>)
285285
throw new Error('Question not found')
286286
}
287287

288-
if (!question.answer_options.includes(answerData.selected_answer)) {
288+
if (!question.answer_options.some(option => option.text === answerData.selected_answer)) {
289289
throw new Error('Invalid answer option')
290290
}
291291

@@ -361,34 +361,34 @@ export async function validateAdmin(username: string, password: string, event?:
361361
}
362362

363363
// Get results for current question
364-
export async function getResultsForQuestion(questionId: string): Promise<Results | null> {
365-
const questions = await getQuestions()
364+
export async function getResultsForQuestion(questionId: string, allQuestions?: Question[]): Promise<Results | null> {
365+
const questions = allQuestions || await getQuestions()
366366
const question = questions.find(q => q.id === questionId)
367367
if (!question) return null
368368

369369
const answers = await getAnswersForQuestion(question.id)
370370

371371
// Count votes for each option
372-
const results: Record<string, number> = {}
372+
const results: Record<string, { count: number, emoji?: string }> = {}
373373
question.answer_options.forEach((option) => {
374-
results[option] = 0
374+
results[option.text] = { count: 0, emoji: option.emoji }
375375
})
376376

377377
answers.forEach((answer) => {
378378
if (Object.prototype.hasOwnProperty.call(results, answer.selected_answer)) {
379-
const currentCount = results[answer.selected_answer]
380-
if (typeof currentCount === 'number') {
381-
results[answer.selected_answer] = currentCount + 1
379+
const result = results[answer.selected_answer]
380+
if (result) {
381+
result.count++
382382
}
383383
}
384384
})
385385

386-
return {
386+
return JSON.parse(JSON.stringify({
387387
question,
388388
results,
389389
totalVotes: answers.length,
390390
totalConnections: (await getPeers()).length
391-
}
391+
}))
392392
}
393393

394394
export async function getCurrentResults(): Promise<Results | null> {

0 commit comments

Comments
 (0)