+8
packages/client/src/router/index.ts
+8
packages/client/src/router/index.ts
+2
packages/client/src/stores/authStore.ts
+2
packages/client/src/stores/authStore.ts
+105
packages/client/src/stores/channelStore.ts
+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
+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
+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
+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