Skip to content

Commit 20ebe29

Browse files
authored
feat: Add live emoji reactions for audience feedback (#26)
1 parent f4d7df0 commit 20ebe29

File tree

9 files changed

+302
-36
lines changed

9 files changed

+302
-36
lines changed

app/components/ui/UiButton.vue

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ defineProps({
1919
<button
2020
:type="type"
2121
:class="{
22-
'bg-black text-white text-base uppercase cursor-pointer transition-all duration-300 hover:bg-white hover:text-black hover:shadow-inner-black': variant === 'primary',
23-
'bg-white text-black border-2 border-black cursor-pointer uppercase hover:bg-black hover:text-white': variant === 'secondary',
24-
'bg-white text-black border-[3px] border-black no-underline uppercase text-lg transition-all duration-300 cursor-pointer hover:bg-black hover:text-white hover:-translate-y-1 hover:shadow-[0_5px_0_#000]': variant === 'link',
25-
'bg-red-600 text-white border-2 border-red-600 cursor-pointer uppercase hover:bg-red-700': variant === 'danger',
22+
'bg-black text-white text-base uppercase cursor-pointer transition-all duration-300 hover:bg-white hover:text-black hover:shadow-inner-black disabled:bg-gray-400 disabled:text-gray-700 disabled:cursor-not-allowed disabled:hover:bg-gray-400 disabled:hover:text-gray-700 disabled:hover:shadow-none': variant === 'primary',
23+
'bg-white text-black border-2 border-black cursor-pointer uppercase hover:bg-black hover:text-white disabled:bg-gray-200 disabled:text-gray-500 disabled:border-gray-400 disabled:cursor-not-allowed disabled:hover:bg-gray-200 disabled:hover:text-gray-500': variant === 'secondary',
24+
'bg-white text-black border-[3px] border-black no-underline uppercase text-lg transition-all duration-300 cursor-pointer hover:bg-black hover:text-white hover:-translate-y-1 hover:shadow-[0_5px_0_#000] disabled:bg-gray-200 disabled:text-gray-500 disabled:border-gray-400 disabled:cursor-not-allowed disabled:hover:bg-gray-200 disabled:hover:text-gray-500 disabled:hover:translate-y-0 disabled:hover:shadow-none': variant === 'link',
25+
'bg-red-600 text-white border-2 border-red-600 cursor-pointer uppercase hover:bg-red-700 disabled:bg-red-300 disabled:cursor-not-allowed disabled:hover:bg-red-300': variant === 'danger',
2626
'p-3': size === 'normal',
2727
'py-1 px-2 text-sm': size === 'small'
2828
}"

app/composables/useQuizSocket.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,17 @@ import { createId } from '@paralleldrive/cuid2'
33
import type { Question, Results, UserQuestion } from '~/types'
44

55
export const useQuizSocket = () => {
6-
const config = useRuntimeConfig()
7-
86
const activeQuestion = ref<UserQuestion | Question | null>(null)
97
const selectedAnswer = ref('')
108
const results = ref<Results | null>(null)
119
const totalConnections = ref(0)
1210
const userId = useLocalStorage<string | null>('quiz-user-id', null)
1311

1412
const wsEndpoint = computed(() => {
15-
if (import.meta.client) {
16-
if (!userId.value)
17-
userId.value = createId()
18-
19-
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
20-
const host = config.public.wsUrl || window.location.host
21-
return config.public.wsUrl
22-
? `${config.public.wsUrl}/_ws/default?userId=${userId.value}`
23-
: `${protocol}//${host}/_ws/default?userId=${userId.value}`
13+
if (import.meta.client && !userId.value) {
14+
userId.value = createId()
2415
}
25-
return ''
16+
return getWsEndpoint('default', { userId: userId.value || '' })
2617
})
2718

2819
const { status, data, send, open, close } = useWebSocket(wsEndpoint, {

app/pages/emojis.vue

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<script setup lang="ts">
2+
definePageMeta({
3+
middleware: 'auth'
4+
})
5+
6+
interface Emoji {
7+
id: string
8+
text: string
9+
x: number
10+
y: number
11+
speed: number
12+
rotation: number
13+
}
14+
15+
const emojis = ref<Emoji[]>([])
16+
const { width: windowWidth, height: windowHeight } = useWindowSize()
17+
18+
const route = useRoute()
19+
const scale = computed(() => Number(route.query.scale) || 1)
20+
const transparency = computed(() => {
21+
const val = Number(route.query.transparency) || 1
22+
return Math.min(val, 1)
23+
})
24+
25+
// WebSocket connection
26+
const wsEndpoint = computed(() => getWsEndpoint('default', { channel: 'emojis' }))
27+
const { data } = useWebSocket(wsEndpoint, {
28+
autoReconnect: true,
29+
})
30+
31+
watch(data, (newValue) => {
32+
try {
33+
const event = JSON.parse(newValue)
34+
if (event.event === 'emoji' && event.data?.emoji && event.data?.id) {
35+
addEmoji(event.data.emoji, event.data.id)
36+
}
37+
}
38+
catch (error) {
39+
logger_error('Failed to parse WebSocket message:', error)
40+
}
41+
})
42+
43+
function addEmoji(text: string, id: string) {
44+
const newEmoji: Emoji = {
45+
id,
46+
text,
47+
x: Math.random() * windowWidth.value,
48+
y: -100, // Start above the screen
49+
speed: Math.random() * 3 + 2, // Random speed
50+
rotation: Math.random() * 40 - 20 // Random rotation -20 to 20 deg
51+
}
52+
emojis.value.push(newEmoji)
53+
}
54+
55+
function animateEmojis() {
56+
emojis.value = emojis.value.map(emoji => ({
57+
...emoji,
58+
y: emoji.y + emoji.speed
59+
})).filter(emoji => emoji.y < windowHeight.value + 100) // Remove when off-screen
60+
61+
requestAnimationFrame(animateEmojis)
62+
}
63+
64+
onMounted(() => {
65+
animateEmojis()
66+
})
67+
68+
useHead({
69+
bodyAttrs: {
70+
class: 'emojis-body'
71+
}
72+
})
73+
</script>
74+
75+
<template>
76+
<div class="fixed inset-0 overflow-hidden">
77+
<div
78+
v-for="emoji in emojis"
79+
:key="emoji.id"
80+
class="absolute text-6xl"
81+
:style="{
82+
left: `${emoji.x}px`,
83+
top: `${emoji.y}px`,
84+
transform: `scale(${scale}) rotate(${emoji.rotation}deg)`,
85+
opacity: transparency,
86+
pointerEvents: 'none'
87+
}"
88+
>
89+
{{ emoji.text }}
90+
</div>
91+
</div>
92+
</template>
93+
94+
<style>
95+
.emojis-body {
96+
@apply bg-white;
97+
}
98+
99+
.emojis-body::before {
100+
content: none;
101+
}
102+
</style>

app/pages/index.vue

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
<script setup lang="ts">
2-
import type { Question, UserQuestion } from '~/types'
2+
import type { UserQuestion } from '~/types'
33
44
const userNickname = ref('')
55
const nicknameInput = ref('')
6+
const emojiInput = ref('')
67
const { activeQuestion, selectedAnswer } = useQuizSocket()
78
89
// Load nickname from localStorage
@@ -103,6 +104,59 @@ async function submitAnswer() {
103104
}
104105
}
105106
}
107+
108+
// Submit emoji
109+
const isEmojiCooldown = ref(false)
110+
const cooldownTimerInSec = ref(0)
111+
let cooldownEndTime = 0
112+
113+
const { pause, resume } = useIntervalFn(() => {
114+
const remaining = cooldownEndTime - Date.now()
115+
if (remaining <= 0) {
116+
isEmojiCooldown.value = false
117+
cooldownTimerInSec.value = 0
118+
pause()
119+
}
120+
else {
121+
cooldownTimerInSec.value = remaining / 1000
122+
}
123+
}, 10, { immediate: false })
124+
125+
async function submitEmoji() {
126+
if (isEmojiCooldown.value || !isValidEmoji(emojiInput.value)) {
127+
if (!isValidEmoji(emojiInput.value)) {
128+
alert('Please enter a single emoji.')
129+
}
130+
return
131+
}
132+
133+
try {
134+
await $fetch('/api/emojis/submit', {
135+
method: 'POST',
136+
body: {
137+
emoji: emojiInput.value
138+
}
139+
})
140+
141+
// Start cooldown
142+
isEmojiCooldown.value = true
143+
cooldownEndTime = Date.now() + 1500
144+
cooldownTimerInSec.value = 1.5
145+
resume()
146+
}
147+
catch (error) {
148+
logger_error('Failed to submit emoji:', error)
149+
alert('Failed to send emoji. Please try again.')
150+
}
151+
}
152+
153+
const quickEmojis = ['👍', '❤️', '😂', '🤔', '👏', '']
154+
155+
async function sendQuickEmoji(emoji: string) {
156+
if (isEmojiCooldown.value) return
157+
emojiInput.value = emoji
158+
await submitEmoji()
159+
}
106160
</script>
107161

108162
<template>
@@ -124,13 +178,40 @@ async function submitAnswer() {
124178
</form>
125179
</div>
126180

127-
<!-- Quiz Section -->
181+
<!-- With Nickname -->
128182
<div v-else class="flex flex-col gap-8">
129-
<div class="flex justify-between items-center p-4 bg-white border-[3px] border-black">
183+
<!-- Display Nickname with change function -->
184+
<div class="flex justify-between items-center p-4 bg-white border-[4px] border-black">
130185
<span>Playing as: <strong class="text-lg">{{ userNickname }}</strong></span>
131186
<UiButton @click="changeNickname">Change</UiButton>
132187
</div>
133188

189+
<!-- Emoji Submission -->
190+
<div class="bg-white border-[4px] border-black p-6">
191+
<div class="flex flex-wrap items-center justify-center gap-3">
192+
<button
193+
v-for="emoji in quickEmojis"
194+
:key="emoji"
195+
class="p-2 text-3xl border-2 border-black bg-white transition-transform duration-150 hover:scale-110 disabled:opacity-50 disabled:cursor-not-allowed"
196+
:disabled="isEmojiCooldown"
197+
@click="sendQuickEmoji(emoji)"
198+
>
199+
{{ emoji }}
200+
</button>
201+
<form @submit.prevent="submitEmoji" class="flex items-center">
202+
<UiInput
203+
v-model="emojiInput"
204+
placeholder="?"
205+
class="text-2xl text-center w-16 h-16 flex-shrink-0 border-r-0"
206+
/>
207+
<UiButton type="submit" :disabled="isEmojiCooldown" class="h-16">
208+
<span v-if="isEmojiCooldown">{{ cooldownTimerInSec.toFixed(2) }}s</span>
209+
<span v-else>Send</span>
210+
</UiButton>
211+
</form>
212+
</div>
213+
</div>
214+
134215
<!-- Active Question -->
135216
<div v-if="activeQuestion" class="bg-white border-[4px] border-black p-8">
136217
<div class="flex justify-between items-center mb-4">

app/utils/websocket.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Constructs a WebSocket endpoint URL.
3+
* @param path - The specific WebSocket path (e.g., 'default' or 'emojis').
4+
* @param params - Optional query parameters to append to the URL.
5+
* @returns The fully constructed WebSocket URL.
6+
*/
7+
export function getWsEndpoint(path: string, params: Record<string, string> = {}): string {
8+
if (import.meta.server) {
9+
return ''
10+
}
11+
12+
const config = useRuntimeConfig()
13+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
14+
const host = config.public.wsUrl || window.location.host
15+
const baseUrl = config.public.wsUrl ? `${config.public.wsUrl}/_ws/${path}` : `${protocol}//${host}/_ws/${path}`
16+
17+
const url = new URL(baseUrl)
18+
for (const key in params) {
19+
if (Object.prototype.hasOwnProperty.call(params, key)) {
20+
const value = params[key]
21+
if (value !== undefined) {
22+
url.searchParams.append(key, value)
23+
}
24+
}
25+
}
26+
27+
return url.toString()
28+
}

server/api/emojis/submit.post.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { createId } from '@paralleldrive/cuid2'
2+
3+
export default defineEventHandler(async (event) => {
4+
const body = await readBody(event)
5+
const { emoji } = body
6+
7+
if (!isValidEmoji(emoji)) {
8+
throw createError({
9+
statusCode: 400,
10+
statusMessage: 'Invalid emoji provided. Please provide a single emoji.',
11+
})
12+
}
13+
14+
// Broadcast the emoji with a unique ID to ensure reactivity on the client
15+
broadcast('emoji', { emoji, id: createId() }, 'emojis')
16+
17+
return {
18+
statusCode: 200,
19+
body: { message: 'Emoji received and broadcasted.' },
20+
}
21+
})

server/routes/_ws/default.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import { Peer, Message } from 'crossws'
22

33
export default defineWebSocketHandler({
4-
open(peer) {
4+
async open(peer) {
55
logger('WebSocket connection opened')
6-
const requestUrl = new URL(peer.request.url)
6+
const { url: requestUrlString } = peer.request
7+
const requestUrl = new URL(requestUrlString)
78
const url = requestUrl.pathname
89
const userId = requestUrl.searchParams.get('userId') || undefined
9-
addPeer(peer, url, userId)
10+
const channel = requestUrl.searchParams.get('channel') || 'default'
11+
await addPeer(peer, channel, url, userId)
1012
},
1113

12-
close(peer: Peer) {
14+
async close(peer: Peer) {
1315
logger('WebSocket connection closed')
14-
removePeer(peer)
16+
await removePeer(peer)
1517
},
1618

1719
error(peer: Peer, error: Error) {

0 commit comments

Comments
 (0)