A Vue app that displays bluesky skeets in realtime as they are created.

adds color, simplifies some css

+105 -132
-78
src/assets/base.css
··· 1 - /* color palette from <https://github.com/vuejs/theme> */ 2 - :root { 3 - --vt-c-white: #ffffff; 4 - --vt-c-white-soft: #f8f8f8; 5 - --vt-c-white-mute: #f2f2f2; 6 - 7 - --vt-c-black: #181818; 8 - --vt-c-black-soft: #222222; 9 - --vt-c-black-mute: #282828; 10 - 11 - --vt-c-indigo: #2c3e50; 12 - 13 - --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); 14 - --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); 15 - --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); 16 - --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); 17 - 18 - --vt-c-text-light-1: var(--vt-c-indigo); 19 - --vt-c-text-light-2: rgba(60, 60, 60, 0.66); 20 - --vt-c-text-dark-1: var(--vt-c-white); 21 - --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); 22 - } 23 - 24 - /* semantic color variables for this project */ 25 - :root { 26 - --color-background: var(--vt-c-white); 27 - --color-background-soft: var(--vt-c-white-soft); 28 - --color-background-mute: var(--vt-c-white-mute); 29 - 30 - --color-border: var(--vt-c-divider-light-2); 31 - --color-border-hover: var(--vt-c-divider-light-1); 32 - 33 - --color-heading: var(--vt-c-text-light-1); 34 - --color-text: var(--vt-c-text-light-1); 35 - 36 - --section-gap: 160px; 37 - } 38 - 39 - @media (prefers-color-scheme: dark) { 40 - :root { 41 - --color-background: var(--vt-c-black); 42 - --color-background-soft: var(--vt-c-black-soft); 43 - --color-background-mute: var(--vt-c-black-mute); 44 - 45 - --color-border: var(--vt-c-divider-dark-2); 46 - --color-border-hover: var(--vt-c-divider-dark-1); 47 - 48 - --color-heading: var(--vt-c-text-dark-1); 49 - --color-text: var(--vt-c-text-dark-2); 50 - } 51 - } 52 - 53 - *, 54 - *::before, 55 - *::after { 56 - box-sizing: border-box; 57 - margin: 0; 58 - font-weight: normal; 59 - } 60 - 61 - body { 62 - min-height: 100dvh; 63 - color: var(--color-text); 64 - background: var(--color-background); 65 - transition: 66 - color 0.5s, 67 - background-color 0.5s; 68 - line-height: 1.6; 69 - font-family: monospace; 70 - font-size: 15px; 71 - text-rendering: optimizeLegibility; 72 - -webkit-font-smoothing: antialiased; 73 - -moz-osx-font-smoothing: grayscale; 74 - } 75 - 76 - :focus-visible { 77 - outline: none; 78 - }
+62 -4
src/assets/main.css
··· 1 - @import './base.css'; 1 + :root { 2 + color-scheme: light dark; 3 + --color-background: light-dark(#f8f8f8, #181818); 4 + --color-heading: light-dark(rgb(32, 19, 142), rgb(117, 108, 218)); 5 + --color-text: light-dark(black, rgba(255, 255, 255, 0.7)); 6 + --color-link: var(--color-heading); 7 + --color-link-hover: light-dark(rgb(19, 11, 87), rgb(98, 90, 184)); 8 + --color-input-hover: var(--color-heading); 9 + --color-input-focus-visible: var(var(--color-heading)); 10 + } 11 + 12 + *, 13 + *::before, 14 + *::after { 15 + box-sizing: border-box; 16 + margin: 0; 17 + font-weight: normal; 18 + } 19 + 20 + body { 21 + min-height: 100dvh; 22 + color: var(--color-text); 23 + background: var(--color-background); 24 + transition: 25 + color 0.5s, 26 + background-color 0.5s; 27 + line-height: 1.6; 28 + font-family: monospace; 29 + font-size: 16px; 30 + text-rendering: optimizeLegibility; 31 + -webkit-font-smoothing: antialiased; 32 + -moz-osx-font-smoothing: grayscale; 33 + } 34 + 35 + :focus-visible { 36 + outline: none; 37 + } 2 38 3 39 #app { 4 40 height: 100dvh; ··· 9 45 } 10 46 11 47 a { 12 - color: white; 48 + color: var(--color-link); 49 + 50 + &:hover, 51 + &:visited { 52 + color: var(--color-link-hover); 53 + } 54 + } 55 + 56 + button, 57 + input { 58 + border: 2px solid var(--color-text); 59 + background-color: inherit; 60 + color: var(--color-text); 61 + border-radius: 4px; 62 + font-size: 1rem; 63 + padding: 4px; 64 + font-family: monospace; 65 + 66 + &:hover, 67 + &:focus, 68 + &:focus-visible { 69 + border-color: var(--color-input-hover); 70 + } 13 71 } 14 72 15 - a:visited { 16 - color: grey; 73 + input:invalid { 74 + border-color: red; 17 75 }
+1 -1
src/components/AppHeader.vue
··· 9 9 font-size: larger; 10 10 font-weight: bolder; 11 11 padding-bottom: 0.5rem; 12 - color: white; 12 + color: var(--color-heading); 13 13 } 14 14 </style>
+39 -30
src/components/AppMain.vue
··· 3 3 <div id="search"> 4 4 <div class="inputGroup"> 5 5 <label for="keyword">Keyword(s)</label> 6 - <input type="text" id="keyword" name="keyword" v-model="keywordsString" /> 6 + <input 7 + type="text" 8 + id="keyword" 9 + name="keyword" 10 + pattern="^(?:[^,]+)(?:,\s*[^,]+)*$" 11 + placeholder="keyword(s) (comma-seperated)" 12 + v-model="keywordsString" 13 + /> 7 14 </div> 8 15 <div class="inputGroup"> 9 16 <label for="user">User(s)</label> 10 - <input type="text" id="user" name="user" v-model="usersString" /> 17 + <input 18 + type="text" 19 + id="user" 20 + name="user" 21 + pattern="^(?:(?:[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*\.)+[A-Za-z]{2,})(?:,\s*(?:(?:[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*\.)+[A-Za-z]{2,}))*$" 22 + placeholder="handle(s) (comma-seperated)" 23 + v-model="usersString" 24 + /> 11 25 </div> 12 26 <div class="inputGroup"> 13 27 <label for="keepNumber">Show last skeets</label> ··· 57 71 } else { 58 72 submitWord.value = 'Update' 59 73 connectWebSocket() 60 - setTimeout(updateWebSocket, 500) 61 74 } 62 75 } 63 76 ··· 69 82 return keywords.value.some((keyword) => skeet.text.includes(keyword)) 70 83 } 71 84 85 + const skeetIsFromAuthor = (skeet: Post) => { 86 + if (!users.value) { 87 + return true 88 + } 89 + 90 + return userDids.value.find((userDid) => { 91 + return userDid.did === skeet.authorDid 92 + }) 93 + } 94 + 72 95 const onStop = () => { 73 96 jetstream.value?.close() 74 97 jetstream.value = null ··· 84 107 85 108 jetstream.value.onopen = () => { 86 109 console.log('WebSocket connected') 110 + updateWebSocket(true) 87 111 } 88 112 89 113 jetstream.value.onmessage = (event) => { 90 114 const skeet = websocketToFeedEntry(event.data) 91 - if (skeet && skeetContainsKeywords(skeet)) { 115 + if (skeet && skeetContainsKeywords(skeet) && skeetIsFromAuthor(skeet)) { 92 116 const handle = userDids.value.find((userDid) => { 93 117 return userDid.did === skeet.authorDid 94 118 })?.handle 95 - console.log(handle) 96 119 skeets.value.unshift({ 97 120 ...skeet, 98 121 authorHandle: handle, ··· 112 135 const getDids = async (users: string[]): Promise<{ did: string; handle: string }[]> => { 113 136 const results = await Promise.allSettled( 114 137 users.map(async (user) => { 138 + const trimUser = user.trim() 115 139 try { 116 140 const response = await fetch( 117 - `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${user}`, 141 + `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${trimUser}`, 118 142 ) 119 143 if (!response.ok) { 120 - throw new Error(`Fehler beim Abrufen von ${user}: ${response.status}`) 144 + throw new Error(`Fehler beim Abrufen von ${trimUser}: ${response.status}`) 121 145 } 122 146 const data = await response.json() 123 - return { did: data.did, handle: user } 147 + return { did: data.did, handle: trimUser } 124 148 } catch (error) { 125 - console.error(`Fehler für ${user}:`, error) 149 + console.error(`Fehler für ${trimUser}:`, error) 126 150 return null // Fehlerhafte Anfragen geben `null` zurück 127 151 } 128 152 }), ··· 134 158 .map((result) => (result as PromiseFulfilledResult<{ did: string; handle: string }>).value) 135 159 } 136 160 137 - const updateWebSocket = async () => { 138 - console.info('update user(s) and/or keyword(s)') 139 - let dids: string[] = [] 161 + const updateWebSocket = async (silent = false) => { 162 + if (!silent) { 163 + console.info('update user(s) and/or keyword(s)') 164 + } 165 + 140 166 userDids.value = [] 141 167 if (users.value && users.value[0].length > 1) { 142 168 userDids.value = await getDids(users.value) 143 - dids = userDids.value.map((userDid) => userDid.did) 144 169 } 145 170 jetstream.value?.send( 146 171 JSON.stringify({ 147 172 type: 'options_update', 148 173 payload: { 149 174 wantedCollections: ['app.bsky.feed.post'], 150 - wantedDids: dids, 175 + wantedDids: userDids.value.map((userDid) => userDid.did), 151 176 }, 152 177 }), 153 178 ) ··· 185 210 display: flex; 186 211 gap: 1rem; 187 212 padding: 0.5rem 0; 188 - } 189 - 190 - button, 191 - input { 192 - border: 2px solid var(--color-text); 193 - background-color: inherit; 194 - color: white; 195 - 196 - &:hover, 197 - &:focus { 198 - border-color: white; 199 - } 200 - 201 - &:focus-visible { 202 - border-color: red; 203 - } 204 213 } 205 214 206 215 @media screen and (max-width: 371px) {
-16
src/components/SkeetSearch.vue
··· 1 - <template> 2 - <form class="search"> 3 - <div class="inputGroup"> 4 - <label for="keyword">Keyword(s)</label> 5 - <input type="text" id="keyword" name="keyword" /> 6 - </div> 7 - <div class="inputGroup"> 8 - <label for="user">User(s)</label> 9 - <input type="text" id="user" name="user" /> 10 - </div> 11 - </form> 12 - </template> 13 - 14 - <script setup lang="ts"></script> 15 - 16 - <style lang="scss"></style>
+3 -3
src/components/SkeetView.vue
··· 1 1 <template> 2 2 <div class="skeet-view"> 3 - <p class="skeet-author" v-if="skeet.authorHandle">{{ skeet.authorHandle }}</p> 4 - <a :href="authorLink" class="skeet-author" target="_blank" v-else>{{ skeet.authorDid }}</a> 3 + <a :href="authorLink" class="skeet-author" target="_blank">{{ 4 + skeet.authorHandle ?? skeet.authorDid 5 + }}</a> 5 6 <p class="skeet-date" v-if="skeet.createdAt">{{ skeet.createdAt }}</p> 6 7 <p class="skeet-text">{{ skeet.text }}</p> 7 8 <a :href="skeetLink" class="skeet-link" target="_blank">goto skeet</a> ··· 27 28 <style lang="scss"> 28 29 .skeet-author { 29 30 font-weight: bold; 30 - color: white; 31 31 } 32 32 .skeet-view { 33 33 padding-top: 1rem;