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": {
"@ark-ui/vue": "^5.36.2",
"@lucide/vue": "^1.14.0",
"@tanstack/query-persist-client-core": "^5.100.10",
"@tanstack/vue-query": "^5.100.10",
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@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>
<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>
<img class="chad-avatar__image" :alt="fallback" :src="src" v-bind="api.getImageProps()">
</div>
@@ -18,6 +18,7 @@ defineProps<{
src: string
fallback: string
size?: 'sm'
highlighted?: boolean
}>()
const service = useMachine(avatar.machine, { id: useId() })
@@ -27,24 +28,31 @@ const api = computed(() => avatar.connect(service, normalizeProps))
<style lang="scss">
.chad-avatar {
$self: &;
outline: var(--border-w) solid var(--ink);
outline-offset: calc(var(--border-w) * -1);
width: 40px;
width: 32px;
aspect-ratio: 1;
&__fallback {
@include font-label;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
display: block;
text-align: center;
line-height: 32px;
background-color: var(--grey-2);
#{$self}.highlighted & {
background-color: var(--yellow);
}
}
&__image {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
}
</style>

View File

@@ -7,11 +7,14 @@
:type="type"
:data-full="full || undefined"
>
<slot />
<ChadSpinner v-if="loading" class="chad-button__spinner" />
<slot v-else />
</button>
</template>
<script setup lang="ts">
import ChadSpinner from '@shared/components/ui/Spinner.vue'
defineOptions({
name: 'ChadButton',
})
@@ -61,7 +64,6 @@ interface Props {
}
&[data-disabled],
&[data-loading],
&:disabled {
cursor: not-allowed;
background-color: var(--grey-1);
@@ -69,6 +71,7 @@ interface Props {
}
&[data-loading] {
background-color: var(--grey-1);
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">
<ChannelList class="sidebar__channel-list" />
<ChadButton style="margin: var(--space-2);" @click="logout">
Logout
</ChadButton>
</aside>
<div class="content">
@@ -29,7 +33,9 @@
</template>
<script setup lang="ts">
import ChadButton from '@shared/components/ui/Button.vue'
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'
defineOptions({
@@ -37,6 +43,7 @@ defineOptions({
})
const { version } = useApp()
const { logout } = useAuth()
</script>
<style lang="scss">

View File

@@ -15,15 +15,16 @@ export function qChatMessages() {
queryFn: async ({ pageParam }) => {
const response = await api.chad.chatMessages({ cursor: pageParam, limit: 30 })
return {
messages: response.data.messages.reverse(),
nextCursor: response.data.nextCursor,
}
return response.data
// return {
// messages: response.data.messages.reverse(),
// nextCursor: response.data.nextCursor,
// }
},
select: data => ({
pages: [...data.pages].reverse(),
pageParams: [...data.pageParams].reverse(),
}),
// select: data => ({
// pages: [...data.pages].reverse(),
// pageParams: [...data.pageParams].reverse(),
// }),
initialPageParam: undefined,
getNextPageParam: (lastPage) => {
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'
export const useChat = createGlobalState(() => {
const { data: messages, hasNextPage: hasMoreMessages, fetchNextPage } = qChatMessages()
const { data: messages, hasNextPage: hasMoreMessages, fetchNextPage, isFetching } = qChatMessages()
return {
messages,
hasMoreMessages,
fetchNextPage,
isFetching,
}
})

View File

@@ -1,24 +1,28 @@
<template>
<div class="chat">
<button v-if="hasMoreMessages" type="button" @click="fetchNextPage()">
Load more
</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>
<ChatMessages class="chat__messages" />
<ChatInput class="chat__input" />
</div>
</template>
<script setup lang="ts">
import ChatMessage from '@widgets/chat/ui/ChatMessage.vue'
import { useChat } from '../composables/use-chat'
const { messages, hasMoreMessages, fetchNextPage } = useChat()
import ChatInput from '@widgets/chat/ui/ChatInput.vue'
import ChatMessages from '@widgets/chat/ui/ChatMessages.vue'
</script>
<style lang="scss">
.chat {
display: grid;
grid-template-rows: 1fr auto;
height: 100%;
overflow: hidden;
&__messages {
flex: 1;
}
&__input {
flex: 0 0 auto;
}
}
</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>
<div class="chat-message">
<ChadAvatar class="chat-message__avatar" src="" :fallback="initials" />
<p class="chat-message__sender">
{{ message.senderId }}
</p>
<p class="chat-message__body">
{{ message.text }}
</p>
<ChadAvatar class="chat-message__avatar" src="" :fallback="initials" :highlighted="isMyMessage" />
<div>
<div class="chat-message__top">
<p class="chat-message__sender">
{{ !sender ? message.senderId : sender.displayName }}
</p>
<p class="chat-message__datetime">
{{ datetime }}
</p>
</div>
<p class="chat-message__body">
{{ message.text }}
</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>
</template>
<script setup lang="ts">
import type { ChatMessage } from '@shared/api/generated-chad-api.ts'
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<{
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 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>
<style lang="scss">
.chat-message {
$self: &;
display: grid;
grid-template-columns: auto 1fr;
grid-template-areas: 'avatar sender' 'avatar body';
grid-template-areas: 'avatar top' 'avatar body';
column-gap: var(--space-3);
padding: var(--space-3) var(--space-6);
@@ -34,14 +74,36 @@ const initials = computed(() => props.message.senderId.slice(props.message.sende
grid-area: avatar;
}
&__top {
display: flex;
gap: var(--space-2);
align-items: center;
grid-area: top;
//margin-top: var(--space-1);
}
&__sender {
@include font-body-bold;
}
grid-area: sender;
&__datetime {
@include font-micro;
flex-shrink: 0;
color: var(--grey-3);
}
&__body {
grid-area: body;
margin-top: var(--space-1);
}
&__attachments {
margin-top: var(--space-2);
> *:not(:last-child) {
margin-bottom: var(--space-1);
}
}
}
</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"
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":
version "5.100.10"
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({
username: Type.String(),
id: Type.String(),
}), { $id: 'GetUserQuery' })
export const UserPreferencesSchema = Type.Object({

View File

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