[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
0

Configure Feed

Select the types of activity you want to include in your feed.

roe.dev / app / pages / live.vue
5.9 kB 220 lines
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>