this repo has no description

Compare changes

Choose any two refs to compare.

Changed files
+666 -3
packages
+8
packages/client/src/router/index.ts
··· 55 55 }, 56 56 ], 57 57 }, 58 + { 59 + path: "/app", 60 + name: "app", 61 + component: () => import("../views/app/AppRoot.vue"), 62 + meta: { 63 + title: "app - aurabloom", 64 + }, 65 + }, 58 66 ], 59 67 }); 60 68
+2
packages/client/src/stores/authStore.ts
··· 79 79 } else { 80 80 this.error = error.value || "registration failed"; 81 81 } 82 + } else { 83 + this.isAuthenticated = true; 82 84 } 83 85 } catch (err) { 84 86 this.error = "registration failed";
+105
packages/client/src/stores/channelStore.ts
··· 1 + import type { App as AurabloomApp } from "@aurabloom/server"; 2 + import { treaty } from "@elysiajs/eden"; 3 + import { defineStore } from "pinia"; 4 + 5 + export const useChannelStore = defineStore("channel", { 6 + state: () => ({ 7 + channels: [] as any[], 8 + currentChannel: null as any, 9 + messages: [] as any[], 10 + isLoading: false, 11 + error: null as string | null, 12 + }), 13 + 14 + getters: { 15 + channelList: (state) => state.channels, 16 + currentMessages: (state) => state.messages, 17 + }, 18 + 19 + actions: { 20 + async fetchChannels(communityId: string) { 21 + this.isLoading = true; 22 + this.error = null; 23 + 24 + const api = treaty<AurabloomApp>("http://localhost:3000", { 25 + fetch: { 26 + credentials: "include", 27 + }, 28 + }); 29 + 30 + try { 31 + const { data, error } = await api.api.channels({ communityId }).get(); 32 + 33 + if (data) { 34 + this.channels = data.data; 35 + } 36 + if (error) { 37 + this.error = error.value || "failed to fetch channels"; 38 + } 39 + } catch (err) { 40 + this.error = "failed to fetch channels"; 41 + console.error(err); 42 + } finally { 43 + this.isLoading = false; 44 + } 45 + }, 46 + 47 + async fetchMessages(channelId: string) { 48 + this.isLoading = true; 49 + this.error = null; 50 + 51 + const api = treaty<AurabloomApp>("http://localhost:3000", { 52 + fetch: { 53 + credentials: "include", 54 + }, 55 + }); 56 + 57 + try { 58 + const { data, error } = await api.api.messages({ channelId }).get(); 59 + 60 + if (data) { 61 + this.messages = data.data; 62 + } 63 + if (error) { 64 + this.error = error.value || "failed to fetch messages"; 65 + } 66 + } catch (err) { 67 + this.error = "failed to fetch messages"; 68 + console.error(err); 69 + } finally { 70 + this.isLoading = false; 71 + } 72 + }, 73 + 74 + async sendMessage(channelId: string, content: string) { 75 + this.isLoading = true; 76 + this.error = null; 77 + 78 + const api = treaty<AurabloomApp>("http://localhost:3000", { 79 + fetch: { 80 + credentials: "include", 81 + }, 82 + }); 83 + 84 + try { 85 + const { data, error } = await api.api.messages({ channelId }).post({ 86 + content, 87 + }); 88 + 89 + if (error) { 90 + this.error = error.value || "failed to send message"; 91 + return false; 92 + } 93 + 94 + if (data?.data) this.messages.push(data.data); 95 + return true; 96 + } catch (err) { 97 + this.error = "failed to send message"; 98 + console.error(err); 99 + return false; 100 + } finally { 101 + this.isLoading = false; 102 + } 103 + }, 104 + }, 105 + });
+101
packages/client/src/stores/communityStore.ts
··· 1 + import type { App as AurabloomApp } from "@aurabloom/server"; 2 + import { treaty } from "@elysiajs/eden"; 3 + import { defineStore } from "pinia"; 4 + 5 + export const useCommunityStore = defineStore("community", { 6 + state: () => ({ 7 + communities: [] as any[], 8 + currentCommunity: null as any, 9 + isLoading: false, 10 + error: null as string | null, 11 + }), 12 + 13 + getters: { 14 + communityList: (state) => state.communities, 15 + }, 16 + 17 + actions: { 18 + async fetchPublicCommunities() { 19 + this.isLoading = true; 20 + this.error = null; 21 + 22 + const api = treaty<AurabloomApp>("http://localhost:3000", { 23 + fetch: { 24 + credentials: "include", 25 + }, 26 + }); 27 + 28 + try { 29 + const { data, error } = await api.api.communities.index.get(); 30 + 31 + if (data) { 32 + this.communities = data.data; 33 + } 34 + if (error) { 35 + this.error = error.value || "failed to fetch communities"; 36 + } 37 + } catch (err) { 38 + this.error = "failed to fetch communities"; 39 + console.error(err); 40 + } finally { 41 + this.isLoading = false; 42 + } 43 + }, 44 + 45 + async fetchMyCommunities() { 46 + this.isLoading = true; 47 + this.error = null; 48 + 49 + const api = treaty<AurabloomApp>("http://localhost:3000", { 50 + fetch: { 51 + credentials: "include", 52 + }, 53 + }); 54 + 55 + try { 56 + const { data, error } = await api.api.communities.me.get(); 57 + 58 + if (data) { 59 + this.communities = data.data; 60 + } 61 + if (error) { 62 + this.error = error.value || "failed to fetch your communities"; 63 + } 64 + } catch (err) { 65 + this.error = "failed to fetch your communities"; 66 + console.error(err); 67 + } finally { 68 + this.isLoading = false; 69 + } 70 + }, 71 + 72 + async joinCommunity(communityId: string) { 73 + this.isLoading = true; 74 + this.error = null; 75 + 76 + const api = treaty<AurabloomApp>("http://localhost:3000", { 77 + fetch: { 78 + credentials: "include", 79 + }, 80 + }); 81 + 82 + try { 83 + const { error } = await api.api 84 + .communities({ id: communityId }) 85 + .join.post(); 86 + 87 + if (error) { 88 + this.error = error.value || "failed to join community"; 89 + return false; 90 + } 91 + return true; 92 + } catch (err) { 93 + this.error = "failed to join community"; 94 + console.error(err); 95 + return false; 96 + } finally { 97 + this.isLoading = false; 98 + } 99 + }, 100 + }, 101 + });
+442
packages/client/src/views/app/AppRoot.vue
··· 1 + <script setup lang="ts"> 2 + import { onMounted, ref } from "vue"; 3 + import { useRouter } from "vue-router"; 4 + 5 + import { useAuthStore } from "@/stores/authStore"; 6 + import { useChannelStore } from "@/stores/channelStore"; 7 + import { useCommunityStore } from "@/stores/communityStore"; 8 + import { computed } from "vue"; 9 + 10 + const router = useRouter(); 11 + const authStore = useAuthStore(); 12 + const communityStore = useCommunityStore(); 13 + const channelStore = useChannelStore(); 14 + 15 + const loading = ref(false); 16 + const currentCommunityId = computed(() => communityStore.currentCommunity); 17 + const currentChannelId = computed(() => channelStore.currentChannel); 18 + 19 + const channelListWidth = ref(256); 20 + const isDragging = ref(false); 21 + const isChannelListVisible = ref(true); 22 + 23 + onMounted(async () => { 24 + loading.value = true; 25 + await authStore.fetchCurrentUser(); 26 + if (!authStore.isAuthenticated) router.push({ name: "login" }); 27 + loading.value = false; 28 + 29 + if (authStore.isAuthenticated) await communityStore.fetchMyCommunities(); 30 + }); 31 + 32 + async function selectCommunity(communityId: string) { 33 + communityStore.currentCommunity = communityId; 34 + await channelStore.fetchChannels(communityId); 35 + } 36 + 37 + async function selectChannel(channelId: string) { 38 + channelStore.currentChannel = channelId; 39 + } 40 + 41 + function startDragging() { 42 + isDragging.value = true; 43 + document.addEventListener("mousemove", drag); 44 + document.addEventListener("mouseup", stopDragging); 45 + document.addEventListener("mouseleave", stopDragging); 46 + } 47 + 48 + function drag(event: MouseEvent) { 49 + if (!isDragging.value) return; 50 + 51 + const communityList = document.querySelector(".community-list"); 52 + if (!communityList) return; 53 + const communityListRect = communityList.getBoundingClientRect(); 54 + 55 + if (!isChannelListVisible.value) { 56 + if (event.clientX > communityListRect.right + 50) { 57 + expandChannelList(); 58 + channelListWidth.value = 100; 59 + } 60 + return; 61 + } 62 + 63 + let newWidth = event.clientX - communityListRect.right; 64 + const appContent = document.querySelector(".app-content"); 65 + if (!appContent) return; 66 + const gap = Number.parseInt(getComputedStyle(appContent).gap); 67 + newWidth -= gap * 2; 68 + 69 + if (newWidth < 50) { 70 + collapseChannelList(); 71 + return; 72 + } 73 + 74 + if (newWidth >= 300) newWidth = 300; 75 + else if (newWidth <= 100) newWidth = 100; 76 + 77 + channelListWidth.value = newWidth; 78 + } 79 + 80 + function stopDragging() { 81 + isDragging.value = false; 82 + document.removeEventListener("mousemove", drag); 83 + document.removeEventListener("mouseup", stopDragging); 84 + document.removeEventListener("mouseleave", stopDragging); 85 + } 86 + 87 + function toggleChannelList() { 88 + if (isChannelListVisible.value) collapseChannelList(); 89 + else expandChannelList(); 90 + } 91 + 92 + function collapseChannelList() { 93 + isChannelListVisible.value = false; 94 + } 95 + 96 + function expandChannelList() { 97 + isChannelListVisible.value = true; 98 + } 99 + </script> 100 + 101 + <template> 102 + <div class="app-container" v-if="!loading"> 103 + <div class="app-header"> 104 + <div class="app-header__section"> 105 + <button 106 + @click="isChannelListVisible ? collapseChannelList() : expandChannelList()" 107 + class="show-channels-btn" 108 + title="Show channels"> 109 + <svg v-if="!isChannelListVisible" 110 + width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> 111 + <path d="M6 3L11 8L6 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> 112 + </svg> 113 + <svg v-else 114 + width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> 115 + <path d="M10 3L5 8L10 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> 116 + </svg> 117 + </button> 118 + <p class="brand">aurabloom!</p> 119 + </div> 120 + <div class="app-header__section current-page"> 121 + <div class="icon"></div> 122 + <div class="name"> 123 + <p v-if="currentCommunityId">{{ communityStore.communities.find(c => c.id === currentCommunityId)?.name }}</p> 124 + <p v-else>no community selected</p> 125 + </div> 126 + </div> 127 + <div class="app-header__section user"> 128 + <div class="name"><p>{{ authStore.user?.username }}</p></div> 129 + <div class="icon"></div> 130 + </div> 131 + </div> 132 + 133 + <div class="app-content"> 134 + <div class="app-content__section community-list"> 135 + <div 136 + v-for="community in communityStore.communities" 137 + :key="community.id" 138 + :class="{ active: community.id === currentCommunityId, 'community-item': true }" 139 + :title="community.name" 140 + @click="selectCommunity(community.id)" 141 + > 142 + <div class="icon"></div> 143 + </div> 144 + </div> 145 + 146 + <div 147 + v-show="isChannelListVisible && currentCommunityId && channelStore.channels.length > 0" 148 + class="app-content__section channel-list" 149 + :style="{ width: channelListWidth + 'px' }" 150 + > 151 + <div class="channel-list__item" 152 + v-for="channel in channelStore.channels" 153 + :key="channel.id" 154 + :class="{ active: channel.id === currentChannelId, 'channel-item': true }" 155 + :title="channel.name" 156 + @click="selectChannel(channel.id)" 157 + > 158 + <div class="icon"></div> 159 + <div class="name"><p>{{ channel.name }}</p></div> 160 + </div> 161 + </div> 162 + 163 + <div class="app-content__section chat"> 164 + <div 165 + class="resize-divider" 166 + @mousedown="startDragging" 167 + @dblclick="toggleChannelList"></div> 168 + 169 + <div class="chat__content"> 170 + <div class="chat__content-header"> 171 + <div class="chat__content-header__title"> 172 + <p>{{ channelStore.channels.find(channel => channel.id === currentChannelId)?.name }}</p> 173 + </div> 174 + </div> 175 + <RouterView /> 176 + </div> 177 + </div> 178 + </div> 179 + </div> 180 + <div :class="{ active: loading, 'loading-overlay': true }"> 181 + <p class="loading-spinner">๐ŸŒธ</p> 182 + <h2>aurabloom!</h2> 183 + <p>loading...</p> 184 + </div> 185 + </template> 186 + 187 + <style lang="scss" scoped> 188 + 189 + .app-container { 190 + display: flex; 191 + flex-direction: column; 192 + height: 100vh; 193 + } 194 + 195 + .app-header { 196 + display: flex; 197 + align-items: center; 198 + justify-content: space-between; 199 + color: hsl(var(--subtext0)); 200 + padding: 0.25rem; 201 + font-size: 0.75rem; 202 + 203 + p { 204 + margin: 0; 205 + } 206 + 207 + &__section { 208 + display: flex; 209 + align-items: center; 210 + .brand { 211 + font-weight: 900; 212 + } 213 + &.current-page, &.user { 214 + gap: 0.5rem; 215 + .icon { 216 + aspect-ratio: 1; 217 + background-color: hsl(var(--subtext0)); 218 + width: 1rem; 219 + height: 1rem; 220 + border-radius: 50%; 221 + } 222 + } 223 + } 224 + } 225 + 226 + .show-channels-btn { 227 + background: none; 228 + border: none; 229 + color: hsl(var(--subtext0)); 230 + cursor: pointer; 231 + padding: 0.25rem; 232 + margin-right: 0.5rem; 233 + border-radius: 0.25rem; 234 + display: flex; 235 + align-items: center; 236 + justify-content: center; 237 + transition: background-color 0.2s; 238 + 239 + &:hover { 240 + background-color: hsla(var(--overlay0) / 0.3); 241 + } 242 + 243 + svg { 244 + width: 16px; 245 + height: 16px; 246 + } 247 + } 248 + 249 + .app-content { 250 + display: flex; 251 + flex-direction: row; 252 + gap: 0.5rem; 253 + padding: 0.25rem; 254 + flex-grow: 1; 255 + height: 100%; 256 + 257 + &__section { 258 + display: flex; 259 + flex-direction: column; 260 + 261 + &.community-list { 262 + display: flex; 263 + flex-direction: column; 264 + 265 + .community-item { 266 + display: flex; 267 + align-items: center; 268 + gap: 0.5rem; 269 + cursor: pointer; 270 + 271 + .icon { 272 + aspect-ratio: 1; 273 + background-color: hsl(var(--subtext0)); 274 + width: 2rem; 275 + height: 2rem; 276 + border-radius: 50%; 277 + border: 1px solid transparent; 278 + transition: 0.2s ease-in-out; 279 + } 280 + 281 + &.active .icon { 282 + background-color: hsla(var(--accent) / 0.05); 283 + border: 1px solid hsla(var(--subtext0) / 0.2); 284 + color: hsl(var(--background)); 285 + border-radius: 0.25rem; 286 + } 287 + 288 + &:hover .icon { 289 + border-radius: 0.25rem; 290 + } 291 + } 292 + } 293 + 294 + &.channel-list { 295 + display: flex; 296 + flex-direction: column; 297 + gap: 0.25rem; 298 + 299 + .channel-list__item { 300 + display: flex; 301 + align-items: center; 302 + gap: 0.5rem; 303 + padding: 0.5rem 0.15rem; 304 + cursor: pointer; 305 + font-size: 0.75rem; 306 + font-weight: 500; 307 + color: hsl(var(--text)); 308 + background-color: hsla(var(--accent) / 0); 309 + border: 1px solid hsla(var(--subtext0) / 0); 310 + border-radius: 0.5rem; 311 + 312 + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 313 + 314 + &:hover { 315 + background-color: hsla(var(--accent) / 0.05); 316 + color: hsl(var(--background)); 317 + } 318 + 319 + &.active { 320 + background-color: hsla(var(--accent) / 0.1); 321 + color: hsl(var(--background)); 322 + } 323 + 324 + &:active { 325 + background-color: hsla(var(--accent) / 0.2); 326 + border: 1px solid hsla(var(--accent) / 0.05); 327 + color: hsl(var(--background)); 328 + } 329 + p { 330 + margin: 0; 331 + font-weight: 500; 332 + color: hsl(var(--text)); 333 + } 334 + 335 + &:hover .icon { 336 + border-radius: 0.25rem; 337 + } 338 + } 339 + } 340 + 341 + &.chat { 342 + position: relative; 343 + flex-grow: 1; 344 + background: hsl(var(--crust)); 345 + border: 1px solid hsla(var(--subtext1) / 0.2); 346 + border-radius: 1rem 0.5rem 0.5rem 0.5rem; 347 + overflow: hidden; 348 + transition: all 0.2s ease-out; 349 + 350 + .chat__content-header { 351 + display: flex; 352 + align-items: center; 353 + justify-content: space-between; 354 + padding: 0.5rem; 355 + border-bottom: 1px solid hsla(var(--subtext1) / 0.2); 356 + background-color: hsla(var(--mantle) / 0.9); 357 + 358 + .chat__content-header__title { 359 + flex-grow: 1; 360 + font-size: 1rem; 361 + font-weight: 900; 362 + color: hsl(var(--text)); 363 + p { 364 + margin: 0; 365 + } 366 + } 367 + } 368 + 369 + } 370 + } 371 + } 372 + 373 + .resize-divider { 374 + position: absolute; 375 + left: -2px; 376 + top: 0; 377 + height: 100%; 378 + width: 4px; 379 + border-radius: 1rem; 380 + cursor: ew-resize; 381 + background-color: transparent; 382 + transition: background-color 0.2s; 383 + 384 + &:hover { 385 + background-color: hsla(var(--surface2) / 0.5); 386 + } 387 + } 388 + 389 + .loading-overlay { 390 + position: fixed; 391 + top: 0; 392 + left: 0; 393 + width: 100%; 394 + height: 100%; 395 + background-color: hsla(var(--base) / 0); 396 + display: flex; 397 + align-items: center; 398 + justify-content: center; 399 + flex-direction: column; 400 + z-index: 9999; 401 + pointer-events: none; 402 + transition: 0.3s ease-in-out; 403 + 404 + * { 405 + opacity: 0; 406 + transition: opacity 0.3s ease-in-out; 407 + } 408 + 409 + &.active { 410 + background-color: hsla(var(--base) / 1); 411 + backdrop-filter: blur(1rem); 412 + * { 413 + opacity: 1; 414 + } 415 + } 416 + 417 + p, h2 { 418 + margin: 0; 419 + color: hsl(var(--text)); 420 + } 421 + 422 + .loading-spinner { 423 + font-size: 4rem; 424 + animation: spin 1.5s cubic-bezier(0.68, -0.55, 0.265, 1.55) infinite; 425 + 426 + @keyframes spin { 427 + 0% { 428 + transform: rotate(0deg); 429 + } 430 + 100% { 431 + transform: rotate(360deg); 432 + } 433 + } 434 + } 435 + 436 + p { 437 + margin-top: 0.5rem; 438 + color: hsl(var(--subtext0)); 439 + } 440 + } 441 + 442 + </style>
+8 -3
packages/client/src/views/root/AuthView.vue
··· 61 61 62 62 isSubmitting.value = true; 63 63 64 - if (isRegistration.value) 64 + if (isRegistration.value) { 65 65 await authStore.register({ 66 66 username: username.value, 67 67 password: password.value, 68 68 }); 69 - else 69 + } else { 70 70 await authStore.login({ 71 71 username: username.value, 72 72 password: password.value, 73 73 }); 74 + } 74 75 75 76 isSubmitting.value = false; 76 - if (authStore.isAuthenticated) router.push("/"); 77 + if (authStore.isAuthenticated) redirectToApp(); 78 + }; 79 + 80 + const redirectToApp = () => { 81 + router.push("/app"); 77 82 }; 78 83 </script> 79 84