Skip to content
Open
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
**/node_modules
**/node_modules
.history/
28 changes: 12 additions & 16 deletions frontend/.agents/contexts/OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ Instead, it uses a layered architecture where responsibilities are separated by
- component layer
- core layer
- hooks layer
- services layer
- core services layer
- pages layer
- models layer

Expand All @@ -118,7 +118,7 @@ The application is divided into multiple architecture layers:
| `core/` | Shared infrastructure and configuration |
| `hooks/` | Reusable React hooks |
| `pages/` | Route/page-level components |
| `services/` | API communication |
| `core/services/` | API communication |
| `models/` | Shared TypeScript models |
| `styles/` | Global styling |
| `locales/` | Internationalization |
Expand Down Expand Up @@ -183,7 +183,7 @@ The project architecture separates concerns by technical responsibility instead
| Layer | Main Purpose |
| ------------- | -------------------------------------- |
| `components/` | Shared reusable UI |
| `services/` | API requests and backend communication |
| `core/services/` | API requests and backend communication |
| `hooks/` | Shared reusable logic |
| `pages/` | Route composition |
| `core/` | Shared infrastructure |
Expand Down Expand Up @@ -259,10 +259,6 @@ The architecture is designed to ensure:
│ │ ├── dashboard/
│ │ └── home/
│ │
│ ├── services/ # API services
│ │ ├── auth.service.ts
│ │ └── user.service.ts
│ │
│ ├── styles/ # Global styles
│ │ ├── globals.css
│ │ └── tailwind.css
Expand Down Expand Up @@ -416,15 +412,15 @@ Rules:

---

## 9.8 `src/services/`
## 9.8 `src/core/services/`

Contains backend communication logic.

Examples:

```txt
auth.service.ts
user.service.ts
src/core/services/auth.service.ts
src/core/services/cv.service.ts
```

Responsibilities:
Expand Down Expand Up @@ -479,13 +475,13 @@ Use Zustand for:

# 11. API Architecture

API communication should remain centralized inside the `services/` layer.
API communication should remain centralized inside the `core/services/` layer.

Examples:

```txt
auth.service.ts
user.service.ts
src/core/services/auth.service.ts
src/core/services/cv.service.ts
```

Responsibilities include:
Expand Down Expand Up @@ -574,9 +570,9 @@ Service files use `.service.ts`.
Examples:

```txt
auth.service.ts
user.service.ts
job.service.ts
src/core/services/auth.service.ts
src/core/services/cv.service.ts
src/core/services/job.service.ts
```

---
Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@radix-ui/react-tooltip": "^1.1.8",
"@tanstack/react-query": "^5.66.0",
"@tanstack/react-query-devtools": "^5.75.7",
"agentation": "^3.0.2",
"antd": "^5.24.0",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
Expand Down
10 changes: 2 additions & 8 deletions frontend/src/components/auth/protected-route.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
import { type ReactNode } from 'react'

import { Navigate, Outlet, useLocation } from 'react-router-dom'

import { ROUTE } from '@/core/constants/path'
import { useAuth } from '@/hooks/auth/use-auth'
import { Outlet } from 'react-router-dom'

interface ProtectedRouteProps {
children?: ReactNode
redirectPath?: string
}

const ProtectedRoute = ({ children, redirectPath = ROUTE.PUBLIC.LOGIN }: ProtectedRouteProps) => {
const { isAuthenticated } = useAuth()
const location = useLocation()

const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
// if (!isAuthenticated) {
// return <Navigate to={redirectPath} state={{ from: location }} replace />
// }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
import { MessageSquare, Search } from 'lucide-react'
import { useState } from 'react'

import { MessageSquare, Search } from 'lucide-react'

import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'

export interface ConversationItem {
id: string
name: string
lastMessage: string
timestamp: string
isOnline?: boolean
avatar?: string
unreadCount?: number
}
import { type ConversationItem } from '@/models/interface/chat.interfaces'

interface ChatSidebarProps {
title?: string
Expand Down Expand Up @@ -120,7 +112,8 @@ export default function ChatSidebar({
<ul role='list' className='py-1'>
{filteredConversations.map((conversation, index) => {
const isActive = activeConversationId === conversation.id
const hasUnread = (conversation.unreadCount ?? 0) > 0
const unreadCount = conversation.unreadCount ?? 0
const hasUnread = unreadCount > 0

return (
<li
Expand Down Expand Up @@ -190,7 +183,7 @@ export default function ChatSidebar({
variant='default'
className='h-5 min-w-[20px] px-1.5 text-[10px] font-bold flex-shrink-0'
>
{conversation.unreadCount! > 99 ? '99+' : conversation.unreadCount}
{unreadCount > 99 ? '99+' : unreadCount}
</Badge>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useState, useEffect } from 'react'
import { useEffect, useState } from 'react'

import ChatHeaderNew from './ChatHeaderNew'
import MessageInputNew from './MessageInputNew'
import MessageListNew, { type Message } from './MessageListNew'
import { type Message } from '@/models/interface/chat.interfaces'

import ChatHeader from './ChatHeader'
import MessageInput from './MessageInput'
import MessageList from './MessageList'
import TypingIndicator from './TypingIndicator'

interface ChatWindowProps {
Expand Down Expand Up @@ -30,7 +32,6 @@ export default function ChatWindow({
onReadMessage,
onRetryMessage
}: ChatWindowProps) {
const [isListening, setIsListening] = useState(false)
const [isTyping, setIsTyping] = useState(false)

// Simulate typing indicator when a new message is sent
Expand All @@ -44,18 +45,13 @@ export default function ChatWindow({
return () => clearTimeout(timer)
}
}
}, [messages.length])
}, [messages])

// Reset typing when conversation changes
useEffect(() => {
setIsTyping(false)
}, [conversationId])

const handleStartListening = () => {
setIsListening(!isListening)
// Implement speech-to-text here if needed
}

const handleReadDraft = (text: string) => {
if (!window.speechSynthesis) return

Expand All @@ -69,7 +65,7 @@ export default function ChatWindow({
<div className='w-full flex-1 bg-white flex flex-col overflow-hidden'>
{/* Header */}
<div className='shrink-0'>
<ChatHeaderNew
<ChatHeader
conversationTitle={conversationTitle}
isOnline={isOnline}
onCall={onCall}
Expand All @@ -78,8 +74,8 @@ export default function ChatWindow({
</div>

{/* Message List */}
<div className='flex-1 overflow-y-auto'>
<MessageListNew
<div className='flex-1 overflow-y-auto bg-gray-50/50 chat-bg-pattern'>
<MessageList
messages={messages}
isLoading={isLoading}
onReadMessage={(msg) => onReadMessage?.(msg.content)}
Expand All @@ -95,12 +91,10 @@ export default function ChatWindow({

{/* Message Input */}
<div className='shrink-0'>
<MessageInputNew
<MessageInput
onSendMessage={onSendMessage}
onReadDraft={handleReadDraft}
isLoading={isLoading}
isListening={isListening}
onStartListening={handleStartListening}
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Smile } from 'lucide-react'
import { useState } from 'react'

import { Smile } from 'lucide-react'

import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'

interface EmojiPickerButtonProps {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
import { FormEvent, useRef, useState, useEffect, useCallback } from 'react'
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'

import { Mic, Paperclip, Send, Volume2 } from 'lucide-react'

import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useSpeechInput } from '@/core/hooks/use-speech-input'

import EmojiPickerButton from './EmojiPickerButton'

interface MessageInputProps {
onSendMessage: (text: string) => void
onReadDraft?: (text: string) => void
isLoading?: boolean
isListening?: boolean
onStartListening?: () => void
}

export default function MessageInputNew({
export default function MessageInput({
onSendMessage,
onReadDraft,
isLoading = false,
isListening = false,
onStartListening
isLoading = false
}: MessageInputProps) {
const [message, setMessage] = useState('')
const [sendAnimation, setSendAnimation] = useState(false)
Expand Down Expand Up @@ -71,6 +70,13 @@ export default function MessageInputNew({
textareaRef.current?.focus()
}

const appendSpeechText = useCallback((text: string) => {
setMessage((prev) => (prev.trim() ? `${prev.trim()} ${text}` : text))
textareaRef.current?.focus()
}, [])

const { isListening, startListening } = useSpeechInput(appendSpeechText, 'tin nhắn')

return (
<TooltipProvider delayDuration={300}>
<form
Expand All @@ -79,7 +85,7 @@ export default function MessageInputNew({
className='bg-white border-t border-gray-100 px-4 py-3 flex items-end gap-2'
>
{/* Left action buttons */}
<div className='flex items-center gap-0.5 pb-0.5'>
<div className='flex shrink-0 items-center gap-0.5 pb-0.5'>
{/* Emoji Picker */}
<EmojiPickerButton onEmojiSelect={handleEmojiSelect} disabled={isLoading} />

Expand All @@ -90,7 +96,7 @@ export default function MessageInputNew({
type='button'
aria-label='Đính kèm tệp'
disabled={isLoading}
className='p-2 text-gray-500 hover:text-brand-primary hover:bg-brand-primary/5 rounded-lg
className='flex size-9 shrink-0 items-center justify-center rounded-lg p-2 text-gray-500 hover:text-brand-primary hover:bg-brand-primary/5
transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed
focus:outline-none focus:ring-2 focus:ring-brand-primary/20'
>
Expand All @@ -108,7 +114,7 @@ export default function MessageInputNew({
onClick={handleReadDraft}
aria-label='Đọc bản nháp'
disabled={!hasContent}
className='p-2 text-gray-500 hover:text-violet-500 hover:bg-violet-50 rounded-lg
className='flex size-9 shrink-0 items-center justify-center rounded-lg p-2 text-gray-500 hover:text-violet-500 hover:bg-violet-50
transition-all duration-200 disabled:opacity-30 disabled:cursor-not-allowed
focus:outline-none focus:ring-2 focus:ring-brand-primary/20'
>
Expand All @@ -120,7 +126,7 @@ export default function MessageInputNew({
</div>

{/* Textarea */}
<div className='flex-1'>
<div className='min-w-0 flex-1'>
<label htmlFor='chat-message-input' className='sr-only'>
Nhập tin nhắn
</label>
Expand All @@ -143,16 +149,17 @@ export default function MessageInputNew({
</div>

{/* Right action buttons */}
<div className='flex items-center gap-0.5 pb-0.5'>
<div className='flex shrink-0 items-center gap-0.5 pb-0.5'>
{/* Microphone */}
<Tooltip>
<TooltipTrigger asChild>
<button
type='button'
onClick={onStartListening}
onClick={startListening}
disabled={isLoading}
aria-label={isListening ? 'Dừng ghi âm' : 'Nhập bằng giọng nói'}
className={`p-2 rounded-lg transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-brand-primary/20
className={`flex size-9 shrink-0 items-center justify-center rounded-lg p-2 transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-brand-primary/20 disabled:opacity-50 disabled:cursor-not-allowed
${
isListening
? 'bg-red-50 text-red-500 hover:bg-red-100 chat-online-pulse'
Expand All @@ -174,7 +181,7 @@ export default function MessageInputNew({
type='submit'
disabled={!hasContent || isLoading}
aria-label='Gửi tin nhắn'
className={`p-2.5 rounded-xl transition-all duration-200
className={`flex size-10 shrink-0 items-center justify-center rounded-xl p-2.5 transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-brand-primary/30
${
hasContent
Expand Down
Loading