[READ-ONLY] Mirror of https://github.com/danielroe/roe.dev. This is the code and content for my personal website, built in Nuxt.
roe.dev
1<template>
2 <div class="flex-grow px-4 py-2 md:px-12 md:py-4 w-full">
3 <header class="leading-none mt-[5vw] mb-[1vw] flex justify-between max-w-[37.50rem]">
4 <h1 class="text-2xl">
5 what are you thinking?
6 </h1>
7 <div
8 class="mt-4 text-sm"
9 :class="isConnected ? 'text-green-500' : 'text-red-500'"
10 >
11 {{ isConnected ? 'connected' : 'connecting...' }}
12 </div>
13 </header>
14
15 <main
16 id="main-content"
17 class="text-muted text-lg max-w-[37.50rem]"
18 >
19 <div class="fixed inset-0 pointer-events-none overflow-hidden">
20 <transition-group name="fade">
21 <div
22 v-for="reaction in displayedReactions"
23 :key="reaction.id"
24 class="emoji-reaction absolute pointer-events-none select-none z-999"
25 :style="{
26 left: `${reaction.position.x}%`,
27 bottom: `${reaction.position.y}%`,
28 fontSize: `${reaction.size}px`,
29 }"
30 >
31 {{ reaction.emoji }}
32 </div>
33 </transition-group>
34 </div>
35
36 <div class="grid grid-cols-4 gap-3 mb-4 touch-manipulation">
37 <button
38 v-for="emoji in predefinedEmojis"
39 :key="emoji"
40 type="button"
41 class="aspect-square text-[1.75rem] rounded-lg flex items-center justify-center bg-muted bg-opacity-10 transition-all duration-200 hover:bg-opacity-20 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed select-none f-ring"
42 :disabled="!isConnected"
43 :aria-label="`Send ${emoji} reaction`"
44 @click="sendEmoji(emoji)"
45 @dblclick="sendEmoji(emoji, 3)"
46 @touchstart.passive="preventZoom"
47 >
48 {{ emoji }}
49 </button>
50
51 <form
52 class="aspect-square rounded-lg bg-muted/15 transition-all duration-200 hover:bg-muted/25"
53 :class="{ 'opacity-50 cursor-not-allowed': !isConnected }"
54 @submit.prevent="() => sendCustomEmoji(customEmoji)"
55 >
56 <label class="flex flex-col items-center justify-center h-full touch-manipulation">
57 <input
58 v-model="customEmoji"
59 type="text"
60 class="w-14 h-14 text-center text-[1.75rem] bg-transparent border-none rounded-lg f-ring-inset"
61 :disabled="!isConnected"
62 aria-label="Custom emoji input"
63 @touchstart.passive="preventZoom"
64 >
65 <span class="text-xs text-muted mt-1">custom</span>
66 </label>
67 <button
68 type="submit"
69 class="sr-only"
70 >
71 Submit
72 </button>
73 </form>
74 </div>
75 </main>
76 </div>
77</template>
78
79<script lang="ts" setup>
80import { ref, onBeforeUnmount } from 'vue'
81import PartySocket from 'partysocket'
82
83const predefinedEmojis = ['👍', '❤️', '😂', '👏', '🔥', '🎉', '🤯', '👨💻', '🙌', '🎯', '✅']
84const customEmoji = ref('')
85const displayedReactions = ref<Array<{
86 id: string
87 emoji: string
88 position: { x: number, y: number }
89 size: number
90}>>([])
91const isConnected = ref(false)
92
93// Connect to PartyKit
94let partySocket: PartySocket
95if (import.meta.client) {
96 onNuxtReady(() => {
97 partySocket = new PartySocket({
98 host: import.meta.dev ? 'localhost:1999' : 'v.danielroe.partykit.dev',
99 room: 'reactions',
100 })
101
102 partySocket.onopen = () => {
103 isConnected.value = true
104 }
105 })
106
107 onBeforeUnmount(() => partySocket?.close())
108 onDeactivated(() => partySocket?.close())
109 onActivated(() => partySocket?.reconnect())
110}
111
112async function sendEmoji (emoji: string, count = 1) {
113 if (!isConnected.value) return
114 for (let i = 0; i < count; i++) {
115 partySocket.send(`reaction:${emoji}`)
116 displayReaction(emoji)
117 await new Promise(resolve => setTimeout(resolve, 100))
118 }
119}
120
121watch(customEmoji, sendCustomEmoji)
122
123function sendCustomEmoji (emoji: string) {
124 if (!emoji?.trim() || !isConnected.value) return
125
126 // Basic client-side emoji validation
127 if (!isValidEmoji(emoji)) {
128 customEmoji.value = ''
129 return
130 }
131
132 sendEmoji(emoji)
133 customEmoji.value = ''
134}
135
136function displayReaction (emoji: string) {
137 const id = Date.now().toString() + Math.random().toString()
138 const size = Math.random() * 20 + 30 // 30-50px size
139
140 // Create the reaction with position information only
141 const newReaction = {
142 id,
143 emoji,
144 position: {
145 x: Math.random() * 80 + 10, // 10-90% horizontal
146 y: 5, // Start near bottom (5% from bottom)
147 },
148 size,
149 }
150
151 displayedReactions.value.push(newReaction)
152
153 // Remove the reaction after animation completes (matches the CSS animation duration)
154 setTimeout(() => {
155 displayedReactions.value = displayedReactions.value.filter(r => r.id !== id)
156 }, 2500) // 2.5s = animation duration
157}
158
159function preventZoom () {
160 // This helps prevent double-tap zoom on iOS/mobile
161}
162</script>
163
164<style scoped>
165@keyframes float-up {
166 0% {
167 opacity: 0;
168 transform: translateY(0) scale(0.9);
169 }
170
171 8% {
172 opacity: 1;
173 transform: translateY(-30px) scale(1);
174 }
175
176 85% {
177 opacity: 0.9;
178 transform: translateY(-70vh) scale(1);
179 }
180
181 100% {
182 opacity: 0;
183 transform: translateY(-90vh) scale(0.9);
184 }
185}
186
187.emoji-reaction {
188 line-height: 1;
189 text-shadow: 0 0 5px rgb(0 0 0 / 20%);
190}
191
192@media (prefers-reduced-motion: no-preference) {
193 .emoji-reaction {
194 animation: float-up 2.5s cubic-bezier(0.25, 0.1, 0.25, 1) forwards;
195 will-change: transform, opacity;
196 }
197
198 .fade-enter-active,
199 .fade-leave-active {
200 transition: opacity 0.2s ease-in-out;
201 }
202
203 .fade-enter-from,
204 .fade-leave-to {
205 opacity: 0;
206 }
207}
208
209@media (prefers-reduced-motion: reduce) {
210 .emoji-reaction {
211 display: none;
212 }
213}
214
215input::-webkit-inner-spin-button,
216input::-webkit-outer-spin-button {
217 appearance: none;
218 margin: 0;
219}
220</style>