bookwiz.io / components / Message.tsx
Message.tsx
Raw
import React, { memo, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import Image from 'next/image'
import { IoTrashOutline } from 'react-icons/io5'
import { MessageUI } from '@/lib/types/database'

interface MessageProps {
  message: MessageUI & { _isStreaming?: boolean }
  index: number
  isStreaming?: boolean
  isLastMessage?: boolean
  onViewDiff?: () => void
  getAvatarUrl: () => string | null
  getInitials: () => string
  onFileClick?: (fileId: string, fileName: string) => void
  onDeleteMessage?: (messageId: string) => Promise<void>
}

// Custom link renderer for file links
const FileLink = ({ href, children, onFileClick }: { 
  href?: string
  children: React.ReactNode
  onFileClick?: (fileId: string, fileName: string) => void 
}) => {
  const handleClick = (e: React.MouseEvent) => {
    e.preventDefault()
    if (href && onFileClick) {
      if (href.startsWith('file://')) {
        const fileId = href.replace('file://', '')
        const fileName = typeof children === 'string' ? children.replace(/^📄\s*/, '') : 'Unknown File'
        onFileClick(fileId, fileName)
      }
      // Future: handle folder:// links for folder navigation
    }
  }

  if (href?.startsWith('file://') || href?.startsWith('folder://')) {
    return (
      <button
        onClick={handleClick}
        className="text-teal-400 hover:text-teal-300 underline decoration-dotted hover:decoration-solid transition-all duration-150 bg-transparent border-none p-0 font-inherit cursor-pointer"
        title={href.startsWith('file://') ? 'Click to open file' : 'Click to navigate to folder'}
      >
        {children}
      </button>
    )
  }

  // Regular external link
  return (
    <a 
      href={href} 
      target="_blank" 
      rel="noopener noreferrer"
      className="text-blue-400 hover:text-blue-300 underline"
    >
      {children}
    </a>
  )
}

const Message = memo(function Message({
  message,
  index,
  isStreaming,
  isLastMessage,
  onViewDiff,
  getAvatarUrl,
  getInitials,
  onFileClick,
  onDeleteMessage
}: MessageProps) {
  const [isDeleting, setIsDeleting] = useState(false)
  const [showDeleteButton, setShowDeleteButton] = useState(false)

  const handleDelete = async () => {
    if (!onDeleteMessage || isDeleting) return
    
    setIsDeleting(true)
    try {
      await onDeleteMessage(message.id.toString())
    } catch (error) {
      console.error('Error deleting message:', error)
    } finally {
      setIsDeleting(false)
    }
  }

  return (
    <div
      className={`flex items-start gap-1.5 group relative ${
        message.type === 'user' ? 'justify-end' : 'justify-start'
      }`}
      onMouseEnter={() => setShowDeleteButton(true)}
      onMouseLeave={() => setShowDeleteButton(false)}
    >
      {/* AI Avatar - reduced size */}
      {message.type === 'ai' && (
        <div className="flex-shrink-0 w-5 h-5 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center shadow-sm border border-slate-600">
          <span className="text-[10px]">🤖</span>
        </div>
      )}
      
      {/* Message Content - reduced padding */}
      <div
        className={`max-w-[85%] px-3 py-2 rounded-lg relative ${
          message.type === 'user'
            ? 'bg-slate-700/60 backdrop-blur-sm text-slate-100 border border-slate-600/50'
            : 'bg-white/5 backdrop-blur-sm text-slate-100 shadow-sm border border-white/10'
        }`}
      >
        {/* Delete Button - appears on hover */}
        {showDeleteButton && onDeleteMessage && !(message as any)._isStreaming && (
          <button
            onClick={handleDelete}
            disabled={isDeleting}
            className="absolute -top-1 -right-1 p-1 bg-slate-800/80 hover:bg-red-500/90 text-slate-400 hover:text-white rounded-full shadow-sm transition-all duration-300 opacity-0 group-hover:opacity-100 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed z-10 backdrop-blur-sm border border-slate-600/30 hover:border-red-400/50"
            title="Delete message"
          >
            {isDeleting ? (
              <div className="w-3 h-3 border border-current border-t-transparent rounded-full animate-spin" />
            ) : (
              <IoTrashOutline className="w-3 h-3 transition-transform duration-200 group-hover:scale-110" />
            )}
          </button>
        )}

        {/* Enhanced markdown rendering with file link support */}
        <div className="prose prose-sm prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
          <ReactMarkdown
            components={{
              a: ({ href, children }) => (
                <FileLink href={href} onFileClick={onFileClick}>
                  {children}
                </FileLink>
              ),
              // Enhance code blocks
              code: ({ className, children, ...props }) => {
                const match = /language-(\w+)/.exec(className || '')
                return (
                  <code
                    className={`${className} bg-slate-900/50 px-1 py-0.5 rounded text-xs`}
                    {...props}
                  >
                    {children}
                  </code>
                )
              },
              // Enhance pre blocks
              pre: ({ children }) => (
                <pre className="bg-slate-900/50 p-2 rounded text-xs overflow-x-auto border border-slate-700/30">
                  {children}
                </pre>
              ),
            }}
          >
            {message.content}
          </ReactMarkdown>
        </div>

        {/* Show streaming indicator */}
        {(message as any)._isStreaming && (
          <div className="flex items-center gap-1 mt-1 opacity-70">
            <div className="w-1 h-1 bg-teal-400 rounded-full animate-pulse"></div>
            <div className="w-1 h-1 bg-teal-400 rounded-full animate-pulse" style={{ animationDelay: '0.2s' }}></div>
            <div className="w-1 h-1 bg-teal-400 rounded-full animate-pulse" style={{ animationDelay: '0.4s' }}></div>
            <span className="text-xs text-slate-400 ml-1">AI is working...</span>
          </div>
        )}
      </div>

      {/* User Avatar - reduced size */}
      {message.type === 'user' && (
        <div className="flex-shrink-0 w-5 h-5">
          {getAvatarUrl() ? (
            <Image
              src={getAvatarUrl()!}
              alt="User avatar"
              width={20}
              height={20}
              className="w-5 h-5 rounded-full border border-slate-600"
            />
          ) : (
            <div className="w-5 h-5 bg-gradient-to-br from-teal-600 to-cyan-600 rounded-full flex items-center justify-center shadow-sm border border-slate-600">
              <span className="text-[8px] font-semibold text-white">
                {getInitials()}
              </span>
            </div>
          )}
        </div>
      )}
    </div>
  )
})

export default Message