refactor(ux): consolidate BMAD skills, update design system, and clean up Prisma generated client

This commit is contained in:
Sepehr Ramezani
2026-04-19 19:21:27 +02:00
parent 5296c4da2c
commit 25529a24b8
2476 changed files with 127934 additions and 101962 deletions

View File

@@ -0,0 +1,189 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import { ChatSidebar } from './chat-sidebar'
import { ChatMessages } from './chat-messages'
import { ChatInput } from './chat-input'
import { createConversation, getConversationDetails, getConversations, deleteConversation } from '@/app/actions/chat-actions'
import { toast } from 'sonner'
import type { UIMessage } from 'ai'
import { useLanguage } from '@/lib/i18n'
interface ChatContainerProps {
initialConversations: any[]
notebooks: any[]
}
export function ChatContainer({ initialConversations, notebooks }: ChatContainerProps) {
const { t, language } = useLanguage()
const [conversations, setConversations] = useState(initialConversations)
const [currentId, setCurrentId] = useState<string | null>(null)
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(undefined)
const [historyMessages, setHistoryMessages] = useState<UIMessage[]>([])
const [isLoadingHistory, setIsLoadingHistory] = useState(false)
// Prevents the useEffect from loading an empty conversation
// when we just created one via createConversation()
const skipHistoryLoad = useRef(false)
const transport = useRef(new DefaultChatTransport({
api: '/api/chat',
})).current
const {
messages,
sendMessage,
status,
setMessages,
} = useChat({
transport,
onError: (error) => {
toast.error(error.message || t('chat.assistantError'))
},
})
const isLoading = status === 'submitted' || status === 'streaming'
// Sync historyMessages after each completed streaming response
// so the display doesn't revert to stale history
useEffect(() => {
if (status === 'ready' && messages.length > 0) {
setHistoryMessages([...messages])
}
}, [status, messages])
// Load conversation details when the user selects a different conversation
useEffect(() => {
// Skip if we just created the conversation — useChat already has the messages
if (skipHistoryLoad.current) {
skipHistoryLoad.current = false
return
}
if (currentId) {
const loadMessages = async () => {
setIsLoadingHistory(true)
try {
const details = await getConversationDetails(currentId)
if (details) {
const loaded: UIMessage[] = details.messages.map((m: any, i: number) => ({
id: m.id || `hist-${i}`,
role: m.role as 'user' | 'assistant',
parts: [{ type: 'text' as const, text: m.content }],
}))
setHistoryMessages(loaded)
setMessages(loaded)
}
} catch (error) {
toast.error(t('chat.loadError'))
} finally {
setIsLoadingHistory(false)
}
}
loadMessages()
} else {
setMessages([])
setHistoryMessages([])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentId])
const refreshConversations = useCallback(async () => {
try {
const updated = await getConversations()
setConversations(updated)
} catch {}
}, [])
const handleSendMessage = async (content: string, notebookId?: string) => {
if (notebookId) {
setSelectedNotebook(notebookId)
}
// If no active conversation, create one BEFORE streaming
let convId = currentId
if (!convId) {
try {
const result = await createConversation(content, notebookId || selectedNotebook)
convId = result.id
// Tell the useEffect to skip — we don't want to load an empty conversation
skipHistoryLoad.current = true
setCurrentId(convId)
setHistoryMessages([])
setConversations((prev) => [
{ id: result.id, title: result.title, updatedAt: new Date() },
...prev,
])
} catch {
toast.error(t('chat.createError'))
return
}
}
await sendMessage(
{ text: content },
{
body: {
conversationId: convId,
notebookId: notebookId || selectedNotebook || undefined,
language,
},
}
)
}
const handleNewChat = () => {
setCurrentId(null)
setMessages([])
setHistoryMessages([])
setSelectedNotebook(undefined)
}
const handleDeleteConversation = async (id: string) => {
try {
await deleteConversation(id)
if (currentId === id) {
handleNewChat()
}
await refreshConversations()
} catch {
toast.error(t('chat.deleteError'))
}
}
// During streaming or if useChat has more messages than history, prefer useChat
const displayMessages = isLoading || messages.length > historyMessages.length
? messages
: historyMessages
return (
<div className="flex-1 flex overflow-hidden bg-white dark:bg-[#1a1c22]">
<ChatSidebar
conversations={conversations}
currentId={currentId}
onSelect={setCurrentId}
onNew={handleNewChat}
onDelete={handleDeleteConversation}
/>
<div className="flex-1 flex flex-col h-full overflow-hidden">
<div className="flex-1 overflow-y-auto scrollbar-hide pb-6 w-full flex justify-center">
<ChatMessages messages={displayMessages} isLoading={isLoading || isLoadingHistory} />
</div>
<div className="w-full flex justify-center sticky bottom-0 bg-gradient-to-t from-white dark:from-[#1a1c22] via-white/90 dark:via-[#1a1c22]/90 to-transparent pt-6 pb-4">
<div className="w-full max-w-4xl px-4">
<ChatInput
onSend={handleSendMessage}
isLoading={isLoading}
notebooks={notebooks}
currentNotebookId={selectedNotebook || null}
/>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,129 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { Send, BookOpen, X } from 'lucide-react'
import { getNotebookIcon } from '@/lib/notebook-icon'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { useLanguage } from '@/lib/i18n'
interface ChatInputProps {
onSend: (message: string, notebookId?: string) => void
isLoading?: boolean
notebooks: any[]
currentNotebookId?: string | null
}
export function ChatInput({ onSend, isLoading, notebooks, currentNotebookId }: ChatInputProps) {
const { t } = useLanguage()
const [input, setInput] = useState('')
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(currentNotebookId || undefined)
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (currentNotebookId) {
setSelectedNotebook(currentNotebookId)
}
}, [currentNotebookId])
const handleSend = () => {
if (!input.trim() || isLoading) return
onSend(input, selectedNotebook)
setInput('')
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`
}
}, [input])
return (
<div className="w-full relative">
<div className="relative flex flex-col bg-slate-50 dark:bg-[#202228] rounded-[24px] border border-slate-200/60 dark:border-white/10 shadow-sm focus-within:shadow-md focus-within:border-slate-300 dark:focus-within:border-white/20 transition-all duration-300 overflow-hidden">
{/* Input Area */}
<Textarea
ref={textareaRef}
placeholder={t('chat.placeholder')}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 min-h-[56px] max-h-[40vh] bg-transparent border-none focus-visible:ring-0 resize-none py-4 px-5 text-[15px] placeholder:text-slate-400"
/>
{/* Bottom Actions Bar */}
<div className="flex items-center justify-between px-3 pb-3 pt-1">
{/* Context Selector */}
<div className="flex items-center gap-2">
<Select
value={selectedNotebook || 'global'}
onValueChange={(val) => setSelectedNotebook(val === 'global' ? undefined : val)}
>
<SelectTrigger className="h-8 w-auto min-w-[130px] rounded-full bg-white dark:bg-[#1a1c22] border-slate-200 dark:border-white/10 shadow-sm text-xs font-medium gap-2 ring-offset-transparent focus:ring-0 focus:ring-offset-0 hover:bg-slate-50 dark:hover:bg-[#252830] transition-colors">
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
<SelectValue placeholder={t('chat.allNotebooks')} />
</SelectTrigger>
<SelectContent className="rounded-xl shadow-lg border-slate-200 dark:border-white/10">
<SelectItem value="global" className="rounded-lg text-sm text-muted-foreground">{t('chat.inAllNotebooks')}</SelectItem>
{notebooks.map((nb) => (
<SelectItem key={nb.id} value={nb.id} className="rounded-lg text-sm">
{(() => {
const Icon = getNotebookIcon(nb.icon)
return <Icon className="w-3.5 h-3.5" />
})()} {nb.name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedNotebook && (
<Badge variant="secondary" className="text-[10px] bg-primary/10 text-primary border-none rounded-full px-2.5 h-6 font-semibold tracking-wide">
{t('chat.active')}
</Badge>
)}
</div>
{/* Send Button */}
<Button
disabled={!input.trim() || isLoading}
onClick={handleSend}
size="icon"
className={cn(
"rounded-full h-8 w-8 transition-all duration-200",
input.trim() ? "bg-primary text-primary-foreground shadow-sm hover:scale-105" : "bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500"
)}
>
<Send className="h-4 w-4 ml-0.5" />
</Button>
</div>
</div>
<div className="text-center mt-3">
<span className="text-[11px] text-muted-foreground/60 w-full block">
{t('chat.disclaimer')}
</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,84 @@
'use client'
import { User, Bot, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { useLanguage } from '@/lib/i18n'
interface ChatMessagesProps {
messages: any[]
isLoading?: boolean
}
function getMessageContent(msg: any): string {
if (typeof msg.content === 'string') return msg.content
if (msg.parts && Array.isArray(msg.parts)) {
return msg.parts
.filter((p: any) => p.type === 'text')
.map((p: any) => p.text)
.join('')
}
return ''
}
export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
const { t } = useLanguage()
return (
<div className="w-full max-w-4xl flex flex-col pt-8 pb-4">
{messages.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center h-[60vh] text-center space-y-6">
<div className="p-5 bg-gradient-to-br from-primary/10 to-primary/5 rounded-full shadow-inner ring-1 ring-primary/10">
<Bot className="h-12 w-12 text-primary opacity-60" />
</div>
<p className="text-muted-foreground text-sm md:text-base max-w-md px-4 font-medium">
{t('chat.welcome')}
</p>
</div>
)}
{messages.map((msg, index) => {
const content = getMessageContent(msg)
const isLastAssistant = msg.role === 'assistant' && index === messages.length - 1 && isLoading
return (
<div
key={msg.id || index}
className={cn(
"flex w-full px-4 md:px-0 py-6 my-2 group",
msg.role === 'user' ? "justify-end" : "justify-start border-y border-transparent dark:border-transparent"
)}
>
{msg.role === 'user' ? (
<div dir="auto" className="max-w-[85%] md:max-w-[70%] bg-[#f4f4f5] dark:bg-[#2a2d36] text-slate-800 dark:text-slate-100 rounded-3xl rounded-br-md px-6 py-4 shadow-sm border border-slate-200/50 dark:border-white/5">
<div className="prose prose-sm dark:prose-invert max-w-none text-[15px] leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
</div>
) : (
<div className="flex gap-4 md:gap-6 w-full max-w-3xl">
<Avatar className="h-8 w-8 shrink-0 bg-transparent border border-primary/20 text-primary mt-1 shadow-sm">
<AvatarFallback className="bg-transparent"><Bot className="h-4 w-4" /></AvatarFallback>
</Avatar>
<div dir="auto" className="flex-1 overflow-hidden pt-1">
{content ? (
<div className="prose prose-slate dark:prose-invert max-w-none prose-p:leading-relaxed prose-pre:bg-slate-900 prose-pre:shadow-sm prose-pre:border prose-pre:border-slate-800 prose-headings:font-semibold marker:text-primary/50 text-[15px] prose-table:border prose-table:border-slate-300 prose-th:border prose-th:border-slate-300 prose-th:px-3 prose-th:py-2 prose-th:bg-slate-100 dark:prose-th:bg-slate-800 prose-td:border prose-td:border-slate-300 prose-td:px-3 prose-td:py-2">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
) : isLastAssistant ? (
<div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-[15px] animate-pulse">{t('chat.searching')}</span>
</div>
) : null}
</div>
</div>
)}
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,127 @@
'use client'
import { useState } from 'react'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { MessageSquare, Trash2, Plus, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
interface ChatSidebarProps {
conversations: any[]
currentId?: string | null
onSelect: (id: string) => void
onNew: () => void
onDelete?: (id: string) => void
}
export function ChatSidebar({
conversations,
currentId,
onSelect,
onNew,
onDelete,
}: ChatSidebarProps) {
const { t, language } = useLanguage()
const dateLocale = language === 'fr' ? fr : enUS
const [pendingDelete, setPendingDelete] = useState<string | null>(null)
const confirmDelete = (id: string) => {
setPendingDelete(id)
}
const cancelDelete = (e: React.MouseEvent) => {
e.stopPropagation()
setPendingDelete(null)
}
const executeDelete = async (e: React.MouseEvent, id: string) => {
e.stopPropagation()
setPendingDelete(null)
if (onDelete) {
await onDelete(id)
}
}
return (
<div className="w-64 border-r flex flex-col h-full bg-white dark:bg-[#1e2128]">
<div className="p-4 border-bottom">
<Button
onClick={onNew}
className="w-full justify-start gap-2 shadow-sm"
variant="outline"
>
<Plus className="h-4 w-4" />
{t('chat.newConversation')}
</Button>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{conversations.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
{t('chat.noHistory')}
</div>
) : (
conversations.map((chat) => (
<div
key={chat.id}
onClick={() => onSelect(chat.id)}
className={cn(
"relative cursor-pointer rounded-lg transition-all group",
currentId === chat.id
? "bg-primary/10 text-primary dark:bg-primary/20"
: "hover:bg-muted/50 text-muted-foreground"
)}
>
<div className="p-3 flex flex-col gap-1">
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 shrink-0" />
<span className="truncate text-sm font-medium pr-6">
{chat.title || t('chat.untitled')}
</span>
</div>
<span className="text-[10px] opacity-60 ml-6">
{formatDistanceToNow(new Date(chat.updatedAt), { addSuffix: true, locale: dateLocale })}
</span>
</div>
{/* Delete button — visible on hover or when confirming */}
{pendingDelete !== chat.id && (
<button
onClick={(e) => { e.stopPropagation(); confirmDelete(chat.id) }}
className="absolute top-3 right-2 opacity-0 group-hover:opacity-100 p-1 hover:text-destructive transition-all"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
{/* Inline confirmation banner */}
{pendingDelete === chat.id && (
<div
className="flex items-center gap-1.5 px-3 py-1.5 bg-destructive/10 text-destructive text-xs border-t border-destructive/20 rounded-b-lg"
onClick={(e) => e.stopPropagation()}
>
<span className="flex-1 font-medium">{t('chat.deleteConfirm')}</span>
<button
onClick={(e) => executeDelete(e, chat.id)}
className="px-2 py-0.5 bg-destructive text-white rounded text-[10px] font-semibold hover:bg-destructive/90 transition-colors"
>
{t('chat.yes')}
</button>
<button
onClick={cancelDelete}
className="p-0.5 hover:text-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
))
)}
</div>
</div>
)
}