Skip to content

feat: new ui layout #132

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 24 commits into from
Apr 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/frontend/auto-imports.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ declare global {
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
const createWebsocketV2Context: typeof import('./src/composables/useWebsocket')['createWebsocketV2Context']
const createWebsocketV2Context: typeof import('./src/composables/useWebsocketV2')['createWebsocketV2Context']
const customRef: typeof import('vue')['customRef']
const debouncedRef: typeof import('@vueuse/core')['debouncedRef']
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
Expand Down Expand Up @@ -307,6 +307,7 @@ declare global {
const useWebWorker: typeof import('@vueuse/core')['useWebWorker']
const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn']
const useWebsocket: typeof import('./src/composables/useWebsocket')['useWebsocket']
const useWebsocketV2: typeof import('./src/composables/useWebsocketV2')['useWebsocketV2']
const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus']
const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll']
const useWindowSize: typeof import('@vueuse/core')['useWindowSize']
Expand Down Expand Up @@ -338,6 +339,9 @@ declare global {
// @ts-ignore
export type { ClientSendEventFn, ClientCreateWsMessageFn } from './src/composables/useWebsocket'
import('./src/composables/useWebsocket')
// @ts-ignore
export type { WsEventHandler, WsRegisterEventHandler } from './src/composables/useWebsocketV2'
import('./src/composables/useWebsocketV2')
}

// for vue template auto import
Expand Down Expand Up @@ -365,7 +369,7 @@ declare module 'vue' {
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>
readonly createUnrefFn: UnwrapRef<typeof import('@vueuse/core')['createUnrefFn']>
readonly createWebsocketV2Context: UnwrapRef<typeof import('./src/composables/useWebsocket')['createWebsocketV2Context']>
readonly createWebsocketV2Context: UnwrapRef<typeof import('./src/composables/useWebsocketV2')['createWebsocketV2Context']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
readonly debouncedRef: UnwrapRef<typeof import('@vueuse/core')['debouncedRef']>
readonly debouncedWatch: UnwrapRef<typeof import('@vueuse/core')['debouncedWatch']>
Expand Down Expand Up @@ -616,6 +620,7 @@ declare module 'vue' {
readonly useWebWorker: UnwrapRef<typeof import('@vueuse/core')['useWebWorker']>
readonly useWebWorkerFn: UnwrapRef<typeof import('@vueuse/core')['useWebWorkerFn']>
readonly useWebsocket: UnwrapRef<typeof import('./src/composables/useWebsocket')['useWebsocket']>
readonly useWebsocketV2: UnwrapRef<typeof import('./src/composables/useWebsocketV2')['useWebsocketV2']>
readonly useWindowFocus: UnwrapRef<typeof import('@vueuse/core')['useWindowFocus']>
readonly useWindowScroll: UnwrapRef<typeof import('@vueuse/core')['useWindowScroll']>
readonly useWindowSize: UnwrapRef<typeof import('@vueuse/core')['useWindowSize']>
Expand Down
9 changes: 9 additions & 0 deletions apps/frontend/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,32 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
AlertCard: typeof import('./src/components/ui/AlertCard.vue')['default']
ChatGroup: typeof import('./src/components/ui/ChatGroup.vue')['default']
ChatSelector: typeof import('./src/components/ChatSelector.vue')['default']
CheckboxGroup: typeof import('./src/components/ui/CheckboxGroup.vue')['default']
ComposeMessage: typeof import('./src/components/ui/ComposeMessage.vue')['default']
Dialog: typeof import('./src/components/ui/Dialog.vue')['default']
DropdownMenu: typeof import('./src/components/ui/DropdownMenu.vue')['default']
ForwardMessage: typeof import('./src/components/ui/ForwardMessage.vue')['default']
HighlightText: typeof import('./src/components/ui/HighlightText.vue')['default']
IconButton: typeof import('./src/components/ui/IconButton.vue')['default']
ImageMessage: typeof import('./src/components/ui/ImageMessage.vue')['default']
LoadingButton: typeof import('./src/components/ui/LoadingButton.vue')['default']
MessageItem: typeof import('./src/components/ui/MessageItem.vue')['default']
Pagination: typeof import('./src/components/ui/Pagination.vue')['default']
ProgressBar: typeof import('./src/components/ui/ProgressBar.vue')['default']
RadioGroup: typeof import('./src/components/ui/RadioGroup.vue')['default']
RefMessage: typeof import('./src/components/ui/RefMessage.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SearchSelect: typeof import('./src/components/ui/SearchSelect.vue')['default']
SelectDropdown: typeof import('./src/components/ui/SelectDropdown.vue')['default']
Settings: typeof import('./src/components/ui/Settings.vue')['default']
SlotButton: typeof import('./src/components/ui/SlotButton.vue')['default']
StatusBadge: typeof import('./src/components/ui/StatusBadge.vue')['default']
StepIndicator: typeof import('./src/components/ui/StepIndicator.vue')['default']
Switch: typeof import('./src/components/ui/Switch.vue')['default']
TextMessage: typeof import('./src/components/ui/TextMessage.vue')['default']
ThemeToggle: typeof import('./src/components/ThemeToggle.vue')['default']
}
}
4 changes: 3 additions & 1 deletion apps/frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import DefaultLayout from './layouts/default.vue'
<div class="min-h-screen bg-white transition-all duration-300 ease-in-out dark:bg-gray-900">
<Toaster position="top-right" :expand="true" :rich-colors="true" />
<DefaultLayout>
<RouterView />
<template #default="{ changeTitle, setActions, setHidden, setCollapsed }">
<RouterView :change-title="changeTitle" :set-actions="setActions" :set-hidden="setHidden" :set-collapsed="setCollapsed" />
</template>
</DefaultLayout>
</div>
</template>
14 changes: 7 additions & 7 deletions apps/frontend/src/components/ChatSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ watch([selectedType, searchQuery], () => {
<input
v-model="searchQuery"
type="text"
class="w-full border border-gray-300 rounded-md px-4 py-2 dark:border-gray-600 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-100 focus:ring-2 focus:ring-blue-500"
class="w-full border border-secondary rounded-md bg-muted px-4 py-2 focus:border-primary focus:ring-2 focus:ring-primary"
placeholder="Search"
>
</div>
Expand All @@ -118,20 +118,20 @@ watch([selectedType, searchQuery], () => {
:key="chat.id"
class="relative w-full flex active:scale-98 cursor-pointer items-center border rounded-lg p-4 text-left transition-all duration-300 space-x-3 hover:shadow-md hover:-translate-y-0.5"
:class="{
'border-blue-500 bg-blue-50 dark:bg-blue-900/20 shadow-md scale-102': isSelected(chat.id),
'border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-600': !isSelected(chat.id),
'border-primary bg-primary/10 shadow-md scale-102': isSelected(chat.id),
'border-secondary hover:border-primary': !isSelected(chat.id),
}"
@click="toggleSelection(chat.id)"
>
<div class="min-w-0 flex-1">
<div class="focus:outline-none">
<p class="flex items-center gap-2 text-sm text-gray-900 font-medium dark:text-gray-100">
<p class="flex items-center gap-2 text-sm font-medium">
{{ chat.title }}
<span v-if="isSelected(chat.id)" class="text-blue-500 dark:text-blue-400">
<span v-if="isSelected(chat.id)" class="text-primary">
<div class="i-lucide-circle-check h-4 w-4" />
</span>
</p>
<p class="truncate text-sm text-gray-500 dark:text-gray-400">
<p class="truncate text-sm text-secondary-foreground">
{{ chat.subtitle }}
</p>
</div>
Expand All @@ -148,7 +148,7 @@ watch([selectedType, searchQuery], () => {
/>

<!-- No Results Message -->
<div v-if="filteredChats.length === 0" class="py-8 text-center text-gray-500 dark:text-gray-400">
<div v-if="filteredChats.length === 0" class="py-8 text-center text-secondary-foreground">
No chats found
</div>
</div>
Expand Down
62 changes: 62 additions & 0 deletions apps/frontend/src/components/ui/ChatGroup.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<script setup lang="ts">
import type { CoreDialog } from '@tg-search/core'
import type { Chat } from '../../types/chat'
import { ref } from 'vue'

const props = defineProps<{
title: string
avatar: string
icon: string
type: 'user' | 'group' | 'channel'
chats: Chat[]
selectedChatId?: number | null
}>()

const emit = defineEmits<{
(e: 'click', chat: CoreDialog): void
}>()

const active = ref(true)
function toggleActive() {
active.value = !active.value
}
</script>

<template>
<div class="flex cursor-pointer items-center justify-between rounded-md px-4 py-1 text-foreground transition-all duration-300 hover:bg-muted" @click="toggleActive">
<div class="flex cursor-pointer items-center gap-1 text-sm font-medium">
<div class="flex items-center gap-1">
<div :class="props.icon" class="h-4 w-4" />
<span class="select-none">{{ props.title }}</span>
</div>
</div>
<div :class="active ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'" class="h-4 w-4 cursor-pointer" />
</div>
<ul v-show="active" class="scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent max-h-40 overflow-y-auto px-2 space-y-1">
<li v-for="chat in chats" :key="chat.id" :class="{ 'bg-muted': chat.id === props.selectedChatId }" class="rounded-md transition-colors duration-100 hover:bg-muted">
<SlotButton :text="chat.name.slice(0, 22) + (chat.name.length > 22 ? '...' : '')" @click="emit('click', chat)">
<img :alt="`User ${chat.id}`" :src="`https://api.dicebear.com/6.x/bottts/svg?seed=${chat.name}`" class="h-full w-full select-none object-cover">
</SlotButton>
</li>
</ul>
</template>

<style scoped>
/* 自定义滚动条样式 */
.scrollbar-thin::-webkit-scrollbar {
width: 4px;
}

.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}

.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: var(--un-color-muted);
border-radius: 4px;
}

.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: var(--un-color-muted-foreground);
}
</style>
24 changes: 24 additions & 0 deletions apps/frontend/src/components/ui/ComposeMessage.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<script setup lang="ts">
defineProps<{
direction: 'row' | 'column'
}>()
</script>

<template>
<div class="flex gap-2" :class="{ 'flex-row items-stretch': direction === 'row', 'flex-col items-start': direction === 'column' }">
<div class="flex-1">
<slot name="first" />
</div>
<!-- 分割线 -->
<div
class="transition-all duration-300 ease-in-out"
:class="{
'h-[2px] w-full bg-gray-100 dark:bg-gray-800 rounded-full my-2': direction === 'column',
'min-h-full w-[2px] bg-gray-100 dark:bg-gray-800 rounded-full mx-2': direction === 'row',
}"
/>
<div class="flex-1">
<slot name="second" />
</div>
</div>
</template>
91 changes: 43 additions & 48 deletions apps/frontend/src/components/ui/Dialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const emit = defineEmits<{

const dialogRef = ref<HTMLDialogElement | null>(null)
const contentRef = ref<HTMLDivElement | null>(null)
const isVisible = ref(false)

const isOpen = computed({
get: () => props.modelValue,
Expand All @@ -39,7 +40,7 @@ function closeWithAnimation() {
contentRef.value.classList.add('dialog-content-leave')
setTimeout(() => {
isOpen.value = false
}, 200)
}, 100)
}
else {
isOpen.value = false
Expand All @@ -60,13 +61,17 @@ function enableScroll() {

watch(isOpen, (value) => {
if (value) {
isVisible.value = true
disableScroll()
// 打开时重置动画类
if (contentRef.value) {
contentRef.value.classList.remove('dialog-content-leave')
}
}
else {
setTimeout(() => {
isVisible.value = false
}, 100)
enableScroll()
}
})
Expand All @@ -83,74 +88,64 @@ onUnmounted(() => {

<template>
<Teleport to="body">
<Transition name="dialog">
<div
v-if="isOpen"
ref="dialogRef"
class="fixed inset-0 z-50 h-[100dvh] w-[100dvw] overflow-hidden border-black p-4 backdrop-blur-sm"
:class="{ 'cursor-pointer': !persistent }"
@click="handleOutsideClick"
>
<!-- 背景遮罩 -->
<div class="absolute inset-0 h-full w-full bg-black/60 transition-opacity duration-300" />

<!-- 对话框内容 -->
<div class="z-51 h-full w-full flex items-center justify-center">
<div
v-show="isVisible"
ref="dialogRef"
class="fixed inset-0 z-50 h-[100dvh] w-[100dvw] overflow-hidden p-4"
:class="{ 'cursor-pointer': !persistent }"
@click="handleOutsideClick"
>
<!-- 背景遮罩 -->
<Transition name="fade">
<div v-show="isVisible" class="absolute inset-0 h-full w-full backdrop-blur-sm" />
</Transition>

<!-- 对话框内容 -->
<div class="z-51 h-full w-full flex items-center justify-center">
<Transition name="dialog">
<div
v-show="isVisible"
ref="contentRef"
class="dialog-content relative w-full cursor-default rounded-lg bg-white p-6 shadow-2xl ring-1 ring-gray-950/5 dark:bg-gray-800 dark:ring-white/10"
class="dialog-content relative w-full cursor-default rounded-lg bg-popover p-6 shadow-2xl ring-1 ring-secondary/10"
:style="{ maxWidth: maxWidth || '32rem' }"
@click.stop
>
<slot />
</div>
</div>
</Transition>
</div>
</Transition>
</div>
</Teleport>
</template>

<style scoped>
/* 背景动画 */
.dialog-enter-active,
.dialog-leave-active {
transition: opacity 0.3s ease;
/* 背景遮罩动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease-in-out;
}

.dialog-enter-from,
.dialog-leave-to {
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}

/* 内容动画 */
.dialog-content {
animation: dialog-content-enter 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}

.dialog-content-leave {
animation: dialog-content-leave 0.2s cubic-bezier(0.16, 1, 0.3, 1);
/* 对话框内容动画 */
.dialog-enter-active,
.dialog-leave-active {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}

@keyframes dialog-content-enter {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
.dialog-enter-from,
.dialog-leave-to {
opacity: 0;
transform: scale(0.95);
}

@keyframes dialog-content-leave {
from {
opacity: 1;
transform: scale(1) translateY(0);
}
to {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
.dialog-enter-to,
.dialog-leave-from {
opacity: 1;
transform: scale(1);
}

dialog {
Expand Down
6 changes: 3 additions & 3 deletions apps/frontend/src/components/ui/DropdownMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,22 @@ const isOpen = ref(false)

<template>
<div
class="relative"
class="relative bg-popover"
@mouseenter="isOpen = true"
@mouseleave="isOpen = false"
>
<!-- Trigger button -->
<IconButton
:icon="icon"
:aria-label="label"
:class="{ 'text-blue-500': isOpen }"
:class="{ 'text-primary': isOpen }"
class="transition-colors duration-200"
/>

<!-- Dropdown content -->
<div
v-show="isOpen"
class="absolute right-0 z-50 w-48 border border-gray-200 rounded-md bg-white py-1 shadow-lg transition-all duration-200 ease-out dark:border-gray-700 dark:bg-gray-800"
class="absolute right-0 z-50 w-48 border border-secondary rounded-md bg-card py-1 shadow-lg transition-all duration-200 ease-out"
:class="[
isOpen ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-2 pointer-events-none',
]"
Expand Down
Loading