This commit is contained in:
2026-05-14 07:09:52 +06:00
parent edef0a70d2
commit abf4d41c23
18 changed files with 350 additions and 41 deletions

View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@ark-ui/vue": "^5.36.2", "@ark-ui/vue": "^5.36.2",
"@lucide/vue": "^1.14.0", "@lucide/vue": "^1.14.0",
"@tanstack/query-persist-client-core": "^5.100.10",
"@tanstack/vue-query": "^5.100.10", "@tanstack/vue-query": "^5.100.10",
"@tauri-apps/plugin-global-shortcut": "^2.3.1", "@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-opener": "~2", "@tauri-apps/plugin-opener": "~2",

View File

@@ -0,0 +1,22 @@
import type { ResponseError, User } from '@shared/api/generated-chad-api.ts'
import type { MaybeRefOrGetter } from 'vue'
import api from '@shared/api/client.ts'
import { useQuery } from '@tanstack/vue-query'
import { toValue } from 'vue'
export function qUser(username: MaybeRefOrGetter<string>) {
return useQuery<User, ResponseError>({
queryKey: ['user', username],
queryFn: async () => {
const response = await api.chad.userGet({ id: toValue(username) })
return response.data
},
staleTime: Infinity,
// persister: experimental_createQueryPersister({
// storage: localStorage,
// maxAge: 1000 * 60 * 60 * 24,
// prefix: 'CHAD_USER_',
// }),
})
}

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="chad-avatar" v-bind="api.getRootProps()"> <div class="chad-avatar" :class="{ highlighted }" v-bind="api.getRootProps()">
<span class="chad-avatar__fallback" v-bind="api.getFallbackProps()">{{ fallback }}</span> <span class="chad-avatar__fallback" v-bind="api.getFallbackProps()">{{ fallback }}</span>
<img class="chad-avatar__image" :alt="fallback" :src="src" v-bind="api.getImageProps()"> <img class="chad-avatar__image" :alt="fallback" :src="src" v-bind="api.getImageProps()">
</div> </div>
@@ -18,6 +18,7 @@ defineProps<{
src: string src: string
fallback: string fallback: string
size?: 'sm' size?: 'sm'
highlighted?: boolean
}>() }>()
const service = useMachine(avatar.machine, { id: useId() }) const service = useMachine(avatar.machine, { id: useId() })
@@ -27,24 +28,31 @@ const api = computed(() => avatar.connect(service, normalizeProps))
<style lang="scss"> <style lang="scss">
.chad-avatar { .chad-avatar {
$self: &;
outline: var(--border-w) solid var(--ink); outline: var(--border-w) solid var(--ink);
outline-offset: calc(var(--border-w) * -1); outline-offset: calc(var(--border-w) * -1);
width: 40px; width: 32px;
aspect-ratio: 1; aspect-ratio: 1;
&__fallback { &__fallback {
@include font-label; @include font-label;
height: 100%; display: block;
display: flex; text-align: center;
align-items: center; line-height: 32px;
justify-content: center; background-color: var(--grey-2);
#{$self}.highlighted & {
background-color: var(--yellow);
}
} }
&__image { &__image {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
object-position: center;
} }
} }
</style> </style>

View File

@@ -7,11 +7,14 @@
:type="type" :type="type"
:data-full="full || undefined" :data-full="full || undefined"
> >
<slot /> <ChadSpinner v-if="loading" class="chad-button__spinner" />
<slot v-else />
</button> </button>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ChadSpinner from '@shared/components/ui/Spinner.vue'
defineOptions({ defineOptions({
name: 'ChadButton', name: 'ChadButton',
}) })
@@ -61,7 +64,6 @@ interface Props {
} }
&[data-disabled], &[data-disabled],
&[data-loading],
&:disabled { &:disabled {
cursor: not-allowed; cursor: not-allowed;
background-color: var(--grey-1); background-color: var(--grey-1);
@@ -69,6 +71,7 @@ interface Props {
} }
&[data-loading] { &[data-loading] {
background-color: var(--grey-1);
cursor: wait; cursor: wait;
} }
} }

View File

@@ -0,0 +1,43 @@
<template>
<div class="chad-spinner" />
</template>
<script setup lang="ts">
defineOptions({
name: 'ChadSpinner',
})
</script>
<style lang="scss">
.chad-spinner {
--color1: var(--yellow);
--color2: var(--ink);
display: inline-block;
position: relative;
width: 36px;
height: 12px;
background-image: linear-gradient(
to right,
var(--color1) 0%,
var(--color1) 33.333%,
transparent 33.333%,
transparent 66.666%,
var(--color2) 66.666%,
var(--color2) 100%
);
background-repeat: no-repeat;
animation: chad-spinner 400ms infinite;
}
@keyframes chad-spinner {
from {
--color1: var(--yellow);
--color2: var(--ink);
}
50% {
--color1: var(--ink);
--color2: var(--yellow);
}
}
</style>

View File

@@ -0,0 +1,12 @@
import type { MaybeRefOrGetter } from 'vue'
import { qUser } from '@shared/api/qUser.ts'
import { readonly } from 'vue'
export function useUserDetails(username: MaybeRefOrGetter<string>) {
const { data: user, isFetching } = qUser(username)
return {
user: readonly(user),
isFetching,
}
}

View File

@@ -20,6 +20,10 @@
<aside class="sidebar"> <aside class="sidebar">
<ChannelList class="sidebar__channel-list" /> <ChannelList class="sidebar__channel-list" />
<ChadButton style="margin: var(--space-2);" @click="logout">
Logout
</ChadButton>
</aside> </aside>
<div class="content"> <div class="content">
@@ -29,7 +33,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ChadButton from '@shared/components/ui/Button.vue'
import { useApp } from '@shared/composables/use-app.js' import { useApp } from '@shared/composables/use-app.js'
import { useAuth } from '@shared/composables/use-auth.ts'
import ChannelList from '@widgets/channel-list/ui/ChannelList.vue' import ChannelList from '@widgets/channel-list/ui/ChannelList.vue'
defineOptions({ defineOptions({
@@ -37,6 +43,7 @@ defineOptions({
}) })
const { version } = useApp() const { version } = useApp()
const { logout } = useAuth()
</script> </script>
<style lang="scss"> <style lang="scss">

View File

@@ -15,15 +15,16 @@ export function qChatMessages() {
queryFn: async ({ pageParam }) => { queryFn: async ({ pageParam }) => {
const response = await api.chad.chatMessages({ cursor: pageParam, limit: 30 }) const response = await api.chad.chatMessages({ cursor: pageParam, limit: 30 })
return { return response.data
messages: response.data.messages.reverse(), // return {
nextCursor: response.data.nextCursor, // messages: response.data.messages.reverse(),
} // nextCursor: response.data.nextCursor,
// }
}, },
select: data => ({ // select: data => ({
pages: [...data.pages].reverse(), // pages: [...data.pages].reverse(),
pageParams: [...data.pageParams].reverse(), // pageParams: [...data.pageParams].reverse(),
}), // }),
initialPageParam: undefined, initialPageParam: undefined,
getNextPageParam: (lastPage) => { getNextPageParam: (lastPage) => {
return lastPage.nextCursor return lastPage.nextCursor

View File

@@ -0,0 +1,16 @@
import type { ResponseError } from '@shared/api/generated-chad-api.ts'
import type { MaybeRefOrGetter } from 'vue'
import api from '@shared/api/client.ts'
import { useQuery } from '@tanstack/vue-query'
import { toValue } from 'vue'
export function qMessageAttachment(attachmentId: MaybeRefOrGetter<string>) {
return useQuery<Blob, ResponseError>({
queryKey: ['message-attachment', attachmentId],
queryFn: async () => {
const response = await api.chad.attachmentGet(toValue(attachmentId), { format: 'blob' })
return response.data
},
})
}

View File

@@ -2,11 +2,12 @@ import { createGlobalState } from '@vueuse/core'
import { qChatMessages } from '../api/qChatMessages.ts' import { qChatMessages } from '../api/qChatMessages.ts'
export const useChat = createGlobalState(() => { export const useChat = createGlobalState(() => {
const { data: messages, hasNextPage: hasMoreMessages, fetchNextPage } = qChatMessages() const { data: messages, hasNextPage: hasMoreMessages, fetchNextPage, isFetching } = qChatMessages()
return { return {
messages, messages,
hasMoreMessages, hasMoreMessages,
fetchNextPage, fetchNextPage,
isFetching,
} }
}) })

View File

@@ -1,24 +1,28 @@
<template> <template>
<div class="chat"> <div class="chat">
<button v-if="hasMoreMessages" type="button" @click="fetchNextPage()"> <ChatMessages class="chat__messages" />
Load more <ChatInput class="chat__input" />
</button>
<div v-if="messages" class="chat__messages">
<template v-for="(page, pageIdx) in messages.pages" :key="pageIdx">
<ChatMessage v-for="message in page.messages" :key="message.id" :message="message" />
</template>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ChatMessage from '@widgets/chat/ui/ChatMessage.vue' import ChatInput from '@widgets/chat/ui/ChatInput.vue'
import { useChat } from '../composables/use-chat' import ChatMessages from '@widgets/chat/ui/ChatMessages.vue'
const { messages, hasMoreMessages, fetchNextPage } = useChat()
</script> </script>
<style lang="scss"> <style lang="scss">
.chat {
display: grid;
grid-template-rows: 1fr auto;
height: 100%;
overflow: hidden;
&__messages {
flex: 1;
}
&__input {
flex: 0 0 auto;
}
}
</style> </style>

View File

@@ -0,0 +1,16 @@
<template>
<div class="chat-input">
<ChadInput placeholder="Write a message..." />
</div>
</template>
<script setup lang="ts">
import ChadInput from '@shared/components/ui/Input.vue'
</script>
<style lang="scss">
.chat-input {
padding: var(--space-3) var(--space-6);
border-top: var(--border-w) solid var(--ink);
}
</style>

View File

@@ -1,32 +1,72 @@
<template> <template>
<div class="chat-message"> <div class="chat-message">
<ChadAvatar class="chat-message__avatar" src="" :fallback="initials" /> <ChadAvatar class="chat-message__avatar" src="" :fallback="initials" :highlighted="isMyMessage" />
<div>
<div class="chat-message__top">
<p class="chat-message__sender"> <p class="chat-message__sender">
{{ message.senderId }} {{ !sender ? message.senderId : sender.displayName }}
</p> </p>
<p class="chat-message__datetime">
{{ datetime }}
</p>
</div>
<p class="chat-message__body"> <p class="chat-message__body">
{{ message.text }} {{ message.text }}
</p> </p>
<div v-if="message.attachments.length > 0" class="chat-message__attachments">
<ChatMessageAttachment v-for="attachmentId in message.attachments" :key="attachmentId" :attachment-id="attachmentId" />
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ChatMessage } from '@shared/api/generated-chad-api.ts' import type { ChatMessage } from '@shared/api/generated-chad-api.ts'
import ChadAvatar from '@shared/components/ui/Avatar.vue' import ChadAvatar from '@shared/components/ui/Avatar.vue'
import { computed } from 'vue' import { useAuth } from '@shared/composables/use-auth.ts'
import { useUserDetails } from '@shared/composables/use-user-details.ts'
import ChatMessageAttachment from '@widgets/chat/ui/ChatMessageAttachment.vue'
import { format, isThisYear, isToday } from 'date-fns'
import { computed, toRef } from 'vue'
const props = defineProps<{ const props = defineProps<{
message: ChatMessage message: ChatMessage
}>() }>()
const { me } = useAuth()
const isMyMessage = computed(() => {
return props.message.senderId === me.value?.id
})
const { user: sender, isFetching: fetchingSender } = useUserDetails(toRef(() => props.message.senderId))
const initials = computed(() => props.message.senderId.slice(props.message.senderId.length - 2)) const initials = computed(() => props.message.senderId.slice(props.message.senderId.length - 2))
const datetime = computed(() => {
let formatStr = 'd MMMM yyyy, HH:mm'
if (isToday(props.message.createdAt)) {
formatStr = 'HH:mm'
}
else if (isThisYear(props.message.createdAt)) {
formatStr = 'd MMMM, HH:mm'
}
return format(props.message.createdAt, formatStr)
})
</script> </script>
<style lang="scss"> <style lang="scss">
.chat-message { .chat-message {
$self: &;
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
grid-template-areas: 'avatar sender' 'avatar body'; grid-template-areas: 'avatar top' 'avatar body';
column-gap: var(--space-3); column-gap: var(--space-3);
padding: var(--space-3) var(--space-6); padding: var(--space-3) var(--space-6);
@@ -34,14 +74,36 @@ const initials = computed(() => props.message.senderId.slice(props.message.sende
grid-area: avatar; grid-area: avatar;
} }
&__top {
display: flex;
gap: var(--space-2);
align-items: center;
grid-area: top;
//margin-top: var(--space-1);
}
&__sender { &__sender {
@include font-body-bold; @include font-body-bold;
}
grid-area: sender; &__datetime {
@include font-micro;
flex-shrink: 0;
color: var(--grey-3);
} }
&__body { &__body {
grid-area: body; grid-area: body;
margin-top: var(--space-1);
}
&__attachments {
margin-top: var(--space-2);
> *:not(:last-child) {
margin-bottom: var(--space-1);
}
} }
} }
</style> </style>

View File

@@ -0,0 +1,46 @@
<template>
<div class="chat-message-attachment">
<p v-if="isFetching">
Loading...
</p>
<template v-else-if="attachment">
<img v-if="isImage" :src="src" :alt="attachmentId" draggable="false">
</template>
</div>
</template>
<script setup lang="ts">
import { qMessageAttachment } from '@widgets/chat/api/qMessageAttachment.ts'
import { computed, toRef } from 'vue'
const props = defineProps<{
attachmentId: string
}>()
const { isFetching, data: attachment } = qMessageAttachment(toRef(() => props.attachmentId))
const isImage = computed(() => {
return attachment.value?.type.startsWith('image/')
})
const src = computed(() => {
if (!attachment.value || !isImage.value)
return undefined
return URL.createObjectURL(attachment.value)
})
</script>
<style lang="scss">
.chat-message-attachment {
outline: var(--border-w) solid var(--ink);
outline-offset: calc(var(--border-w) * -1);
background-color: var(--grey-1);
width: fit-content;
> img {
max-width: 260px;
max-height: 160px;
}
}
</style>

View File

@@ -0,0 +1,59 @@
<template>
<div class="chat-messages">
<div v-if="messages" ref="scroll" class="chat-messages__list">
<template v-for="(page, pageIdx) in messages.pages" :key="pageIdx">
<ChatMessage v-for="message in page.messages" :key="message.id" :message="message" />
</template>
</div>
<ChadSpinner v-if="isFetching" class="chat-messages__spinner" />
</div>
</template>
<script setup lang="ts">
import ChadSpinner from '@shared/components/ui/Spinner.vue'
import { useInfiniteScroll } from '@vueuse/core'
import ChatMessage from '@widgets/chat/ui/ChatMessage.vue'
import { useTemplateRef } from 'vue'
import { useChat } from '../composables/use-chat'
const scrollRef = useTemplateRef('scroll')
const { messages, hasMoreMessages, fetchNextPage, isFetching } = useChat()
useInfiniteScroll(
scrollRef,
() => {
fetchNextPage()
},
{
distance: 500,
direction: 'top',
interval: 300,
canLoadMore: () => hasMoreMessages.value && !isFetching.value,
},
)
</script>
<style lang="scss">
.chat-messages {
position: relative;
overflow: hidden;
&__spinner {
position: absolute;
top: var(--space-4);
left: 50%;
translate: -50% 0;
z-index: 1;
}
&__list {
overflow-y: auto;
display: flex;
flex-direction: column-reverse;
height: 100%;
}
}
</style>

View File

@@ -818,6 +818,13 @@
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.100.10.tgz#aeb34d301fd4ff9762e67dfa018adc33b7a18be4" resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.100.10.tgz#aeb34d301fd4ff9762e67dfa018adc33b7a18be4"
integrity sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w== integrity sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==
"@tanstack/query-persist-client-core@^5.100.10":
version "5.100.10"
resolved "https://registry.yarnpkg.com/@tanstack/query-persist-client-core/-/query-persist-client-core-5.100.10.tgz#efe9ea2b7cea90330129afdf7393f09fd06ad358"
integrity sha512-O9Pey40DhTTDBABS0bHr+KNL5/VMf6PrqjexS8WoDDtnkaoWM+y0MSe0V9E5W+BwvkjM33mB3aYcCxa175gZTQ==
dependencies:
"@tanstack/query-core" "5.100.10"
"@tanstack/vue-query@^5.100.10": "@tanstack/vue-query@^5.100.10":
version "5.100.10" version "5.100.10"
resolved "https://registry.yarnpkg.com/@tanstack/vue-query/-/vue-query-5.100.10.tgz#eafed0d51887c12dcbba5edb1b7108522ebc3891" resolved "https://registry.yarnpkg.com/@tanstack/vue-query/-/vue-query-5.100.10.tgz#eafed0d51887c12dcbba5edb1b7108522ebc3891"

View File

@@ -2,6 +2,7 @@ import { Type } from 'typebox'
export const GetUserQuerySchema = Type.Partial(Type.Object({ export const GetUserQuerySchema = Type.Partial(Type.Object({
username: Type.String(), username: Type.String(),
id: Type.String(),
}), { $id: 'GetUserQuery' }) }), { $id: 'GetUserQuery' })
export const UserPreferencesSchema = Type.Object({ export const UserPreferencesSchema = Type.Object({

View File

@@ -19,7 +19,7 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
}, },
async (req, reply) => { async (req, reply) => {
const user = await fastify.prisma.user.findFirst({ const user = await fastify.prisma.user.findFirst({
where: { username: req.query.username }, where: { username: req.query.username, id: req.query.id },
select: { select: {
id: true, id: true,
username: true, username: true,