Spaces:
Running
Running
| import React, { useState, useEffect, useRef } from 'react'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| import { useTranslation } from 'react-i18next'; | |
| import { Sparkles, X, Send, Lightbulb, Compass, Award, AlertCircle, Pencil, Loader2 } from 'lucide-react'; | |
| const AIHintOverlay = ({ | |
| isAIActive, | |
| setIsAIActive, | |
| aiLoading, | |
| submitAICanvasPrompt, | |
| chatMessages = [], | |
| setChatMessages, | |
| chatThreads = [], | |
| activeThreadId = 'default', | |
| setActiveThreadId, | |
| createNewThread, | |
| deleteThread, | |
| renameThread | |
| }) => { | |
| const { t } = useTranslation(); | |
| const [prompt, setPrompt] = useState(''); | |
| const [loadingStepIndex, setLoadingStepIndex] = useState(0); | |
| const [isEditingThreadTitle, setIsEditingThreadTitle] = useState(false); | |
| const [editTitleVal, setEditTitleVal] = useState(''); | |
| const messagesEndRef = useRef(null); | |
| const activeThread = chatThreads.find(t => t.id === activeThreadId) || chatThreads[0] || { title: '' }; | |
| const handleStartRename = () => { | |
| setEditTitleVal(activeThread.title); | |
| setIsEditingThreadTitle(true); | |
| }; | |
| const handleFinishRename = () => { | |
| if (editTitleVal.trim()) { | |
| renameThread(activeThreadId, editTitleVal.trim()); | |
| } | |
| setIsEditingThreadTitle(false); | |
| }; | |
| const loadingSteps = [ | |
| "Гостримо графітові олівці...", | |
| "Відкриваємо чистий пергамент...", | |
| "Калібруємо творчий компас...", | |
| "Консультуємось з цифровим оракулом...", | |
| "Малюємо контури вузлів...", | |
| "Накладаємо зв'язки...", | |
| "Задуваємо свіже чорнило..." | |
| ]; | |
| const suggestions = [ | |
| { | |
| text: t('canvas.ai_suggest_details', 'Розпиши поточний крок на підзадачі'), | |
| icon: <Lightbulb size={14} className="text-amber-600" />, | |
| tag: t('canvas.ai_suggest_details_tag', 'Деталізація') | |
| }, | |
| { | |
| text: t('canvas.ai_suggest_route', 'Запропонуй альтернативний шлях на випадок сбою'), | |
| icon: <Compass size={14} className="text-emerald-600" />, | |
| tag: t('canvas.ai_suggest_route_tag', 'Альтернатива') | |
| }, | |
| { | |
| text: t('canvas.ai_suggest_habits', 'Створи щоденні звички для підкріплення цієї цілі'), | |
| icon: <Award size={14} className="text-indigo-600" />, | |
| tag: t('canvas.ai_suggest_habits_tag', 'Звички') | |
| } | |
| ]; | |
| useEffect(() => { | |
| let interval; | |
| if (aiLoading) { | |
| setLoadingStepIndex(0); | |
| interval = setInterval(() => { | |
| setLoadingStepIndex((prev) => (prev + 1) % loadingSteps.length); | |
| }, 3000); | |
| } | |
| return () => clearInterval(interval); | |
| }, [aiLoading]); | |
| // Auto scroll to bottom | |
| useEffect(() => { | |
| if (isAIActive) { | |
| const timer = setTimeout(() => { | |
| messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); | |
| }, 100); | |
| return () => clearTimeout(timer); | |
| } | |
| }, [chatMessages, aiLoading, isAIActive, activeThreadId]); | |
| const handleSubmit = async (e) => { | |
| if (e) e.preventDefault(); | |
| if (!prompt.trim() || aiLoading) return; | |
| const promptToSend = prompt; | |
| setPrompt(''); | |
| await submitAICanvasPrompt(promptToSend); | |
| }; | |
| const handleKeyDown = (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleSubmit(); | |
| } | |
| }; | |
| const parseInlineMarkdown = (text) => { | |
| const parts = []; | |
| let remaining = text; | |
| const boldRegex = /\*\*([^*]+)\*\*/g; | |
| let match; | |
| let lastIndex = 0; | |
| while ((match = boldRegex.exec(text)) !== null) { | |
| const textBefore = text.substring(lastIndex, match.index); | |
| if (textBefore) parts.push(textBefore); | |
| parts.push(<strong key={match.index} className="font-extrabold text-primary">{match[1]}</strong>); | |
| lastIndex = boldRegex.lastIndex; | |
| } | |
| const textAfter = text.substring(lastIndex); | |
| if (textAfter) parts.push(textAfter); | |
| return parts.length > 0 ? parts : text; | |
| }; | |
| const renderMarkdown = (text) => { | |
| if (!text) return null; | |
| return text.split('\n').map((line, idx) => { | |
| const cleanLine = line.trim(); | |
| if (!cleanLine) return <div key={idx} className="h-2" />; | |
| const listMatch = cleanLine.match(/^[\*\-\u2022]\s+(.*)/); | |
| if (listMatch) { | |
| return ( | |
| <li key={idx} className="ml-4 list-disc pl-1 text-sm text-primary/80 font-body my-1"> | |
| {parseInlineMarkdown(listMatch[1])} | |
| </li> | |
| ); | |
| } | |
| const numListMatch = cleanLine.match(/^\d+\.\s+(.*)/); | |
| if (numListMatch) { | |
| return ( | |
| <li key={idx} className="ml-4 list-decimal pl-1 text-sm text-primary/80 font-body my-1"> | |
| {parseInlineMarkdown(numListMatch[1])} | |
| </li> | |
| ); | |
| } | |
| const headingMatch = cleanLine.match(/^(#{1,6})\s+(.*)/); | |
| if (headingMatch) { | |
| const level = headingMatch[1].length; | |
| const headingText = headingMatch[2]; | |
| const classes = level === 1 ? "text-xl font-bold font-display mt-3 mb-1.5" : | |
| level === 2 ? "text-lg font-bold font-display mt-2.5 mb-1.5" : | |
| "text-base font-bold font-display mt-2 mb-1"; | |
| return <div key={idx} className={`${classes} text-primary`}>{parseInlineMarkdown(headingText)}</div>; | |
| } | |
| return ( | |
| <p key={idx} className="text-sm text-primary/85 font-body leading-relaxed mb-1.5 break-words"> | |
| {parseInlineMarkdown(cleanLine)} | |
| </p> | |
| ); | |
| }); | |
| }; | |
| return ( | |
| <AnimatePresence> | |
| {isAIActive && ( | |
| <> | |
| {/* Backdrop overlay (no blur) */} | |
| <motion.div | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| onClick={() => !aiLoading && setIsAIActive(false)} | |
| className="fixed inset-0 bg-primary/20 z-40 cursor-pointer" | |
| /> | |
| {/* Sketchbook Parchment Drawer */} | |
| <motion.div | |
| initial={{ x: '100%', opacity: 0.9 }} | |
| animate={{ x: 0, opacity: 1 }} | |
| exit={{ x: '100%', opacity: 0.9 }} | |
| transition={{ type: 'spring', damping: 25, stiffness: 150 }} | |
| className="fixed top-0 right-0 h-full w-[460px] max-w-full bg-[#fdfbf7] border-l-4 border-primary z-50 shadow-2xl flex flex-col overflow-hidden" | |
| > | |
| {/* Spiral binding rings style visualization on the left edge */} | |
| <div className="absolute left-0 top-0 bottom-0 w-3 flex flex-col justify-around py-8 pointer-events-none z-10"> | |
| {[...Array(12)].map((_, i) => ( | |
| <div key={i} className="w-6 h-3 bg-gradient-to-r from-stone-400 to-stone-600 rounded-full border border-stone-800 -ml-3 shadow-md" /> | |
| ))} | |
| </div> | |
| {/* Notebook Margin Line */} | |
| <div className="absolute left-10 top-0 bottom-0 w-[2px] bg-red-400/40 pointer-events-none" /> | |
| {/* Notebook Paper Lines Pattern */} | |
| <div | |
| className="absolute inset-0 opacity-[0.03] pointer-events-none" | |
| style={{ | |
| backgroundImage: 'linear-gradient(#000 1px, transparent 1px)', | |
| backgroundSize: '100% 28px' | |
| }} | |
| /> | |
| {/* Content Container */} | |
| <div className="flex-grow flex flex-col pl-14 pr-6 py-6 overflow-hidden relative z-0"> | |
| {/* Header */} | |
| <div className="flex items-start justify-between mb-4 border-b-2 border-primary/10 pb-4"> | |
| <div> | |
| <div className="flex items-center gap-2 mb-1"> | |
| <Sparkles className="text-amber-500 animate-pulse" size={20} /> | |
| <span className="font-accent-note text-amber-600 font-bold uppercase tracking-wider text-xs">TaskFlow AI</span> | |
| </div> | |
| <h2 className="font-display text-2xl text-primary leading-none"> | |
| {t('canvas.ai_roadmap_strategist', 'Roadmap Strategist')} | |
| </h2> | |
| </div> | |
| <button | |
| onClick={() => !aiLoading && setIsAIActive(false)} | |
| disabled={aiLoading} | |
| className="p-1.5 border-2 border-primary/20 hover:border-primary rounded-xl transition-all hover:scale-105 cursor-pointer disabled:opacity-50" | |
| > | |
| <X size={18} className="text-primary/70" /> | |
| </button> | |
| </div> | |
| {/* Thread Selector Section */} | |
| <div className="flex items-center justify-between gap-2 mb-3 bg-[#f5efe0]/30 border-2 border-primary/10 p-2 rounded-xl relative"> | |
| <div className="flex-1 flex items-center gap-2 overflow-hidden"> | |
| {isEditingThreadTitle ? ( | |
| <input | |
| type="text" | |
| value={editTitleVal} | |
| onChange={(e) => setEditTitleVal(e.target.value)} | |
| onBlur={handleFinishRename} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter') handleFinishRename(); | |
| if (e.key === 'Escape') setIsEditingThreadTitle(false); | |
| }} | |
| autoFocus | |
| className="flex-grow bg-transparent text-sm font-bold text-primary border-b-2 border-primary outline-none py-0.5 px-1 font-display" | |
| /> | |
| ) : ( | |
| <div className="flex-grow relative flex items-center overflow-hidden"> | |
| <select | |
| value={activeThreadId} | |
| onChange={(e) => setActiveThreadId(e.target.value)} | |
| className="w-full bg-[#faf7f2] border-2 border-primary/30 rounded-lg py-1 pl-2 pr-6 text-xs font-bold text-primary focus:border-primary outline-none cursor-pointer appearance-none font-display" | |
| > | |
| {chatThreads.map(thread => ( | |
| <option key={thread.id} value={thread.id}> | |
| {thread.title} | |
| </option> | |
| ))} | |
| </select> | |
| <span className="absolute right-2 pointer-events-none text-primary/60 text-[10px]">▼</span> | |
| </div> | |
| )} | |
| </div> | |
| <div className="flex items-center gap-1.5"> | |
| {!isEditingThreadTitle && ( | |
| <button | |
| onClick={handleStartRename} | |
| title={t('canvas.ai_rename_title', 'Перейменувати чат')} | |
| className="p-1.5 border-2 border-primary/10 hover:border-primary/40 rounded-lg hover:bg-[#faf7f2] transition-all cursor-pointer flex items-center justify-center" | |
| > | |
| <Pencil size={12} className="text-primary/70" /> | |
| </button> | |
| )} | |
| <button | |
| onClick={() => { | |
| const newId = createNewThread(); | |
| // Start renaming the new thread immediately | |
| setActiveThreadId(newId); | |
| setEditTitleVal(`Чат ${chatThreads.length + 1}`); | |
| setIsEditingThreadTitle(true); | |
| }} | |
| title={t('canvas.ai_new_chat', 'Новий')} | |
| className="h-[28px] border-2 border-primary/30 hover:border-primary rounded-lg bg-[#faf7f2] hover:bg-primary/5 px-2 text-[11px] font-bold text-primary transition-all cursor-pointer flex items-center gap-1 shadow-[1px_1px_0px_0px_rgba(0,0,0,0.1)] active:translate-y-[1px]" | |
| > | |
| <span>+ {t('canvas.ai_new_chat', 'Новий')}</span> | |
| </button> | |
| <button | |
| onClick={() => { | |
| if (window.confirm(t('canvas.ai_delete_confirm', "Ви впевнені, що хочете видалити цей чат?"))) { | |
| deleteThread(activeThreadId); | |
| } | |
| }} | |
| title={t('canvas.ai_delete_title', 'Видалити чат')} | |
| disabled={chatThreads.length <= 1} | |
| className="p-1.5 border-2 border-red-200 hover:border-red-400 rounded-lg hover:bg-red-50 transition-all cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center" | |
| > | |
| <X size={12} className="text-red-600" /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Chat Message Area */} | |
| <div className="flex-1 overflow-y-auto pr-2 mb-4 space-y-4 scrollbar-hide"> | |
| {chatMessages.map((msg) => ( | |
| <div | |
| key={msg.id} | |
| className={`flex flex-col ${msg.sender === 'user' ? 'items-end' : 'items-start'}`} | |
| > | |
| <div | |
| className={`max-w-[90%] p-3.5 rounded-2xl shadow-[2px_2px_0px_0px_rgba(0,0,0,0.15)] border-2 ${ | |
| msg.sender === 'user' | |
| ? 'bg-[#eef5fc] border-blue-500/30 text-primary rounded-tr-none rotate-[0.5deg]' | |
| : msg.isError | |
| ? 'bg-red-50 border-red-500/30 text-red-700 rounded-tl-none rotate-[-0.5deg]' | |
| : 'bg-[#faf7f2] border-primary/20 text-primary rounded-tl-none rotate-[-0.5deg]' | |
| }`} | |
| > | |
| {msg.sender === 'user' ? ( | |
| <p className="text-sm font-body leading-relaxed whitespace-pre-wrap">{msg.text}</p> | |
| ) : ( | |
| <div className="space-y-1"> | |
| {renderMarkdown(msg.text)} | |
| </div> | |
| )} | |
| </div> | |
| {/* Timestamp */} | |
| <span className="text-[9px] font-extrabold text-primary/40 uppercase tracking-widest mt-1.5 px-2"> | |
| {msg.sender === 'user' ? 'Ви' : 'Стратег'} | |
| </span> | |
| </div> | |
| ))} | |
| {/* Loading state showing pencil sketching & steps */} | |
| {aiLoading && ( | |
| <div className="flex flex-col items-start animate-pulse"> | |
| <div className="max-w-[90%] p-4 bg-[#faf7f2] border-2 border-primary/20 rounded-2xl rounded-tl-none shadow-[2px_2px_0px_0px_rgba(0,0,0,0.15)] flex flex-col gap-3"> | |
| <div className="flex items-center gap-3"> | |
| {/* Pencil writing micro-animation */} | |
| <motion.div | |
| animate={{ | |
| x: [0, 8, -8, 6, 0], | |
| y: [0, -5, 8, -4, 0], | |
| rotate: [0, 15, -15, 10, 0] | |
| }} | |
| transition={{ | |
| duration: 2, | |
| repeat: Infinity, | |
| ease: "easeInOut" | |
| }} | |
| className="w-8 h-8 rounded-lg bg-[#faf7f2] border border-primary flex items-center justify-center shadow-sm" | |
| > | |
| <Pencil className="text-amber-500" size={16} /> | |
| </motion.div> | |
| <div className="flex flex-col"> | |
| <span className="text-xs font-bold text-primary">Стратег планує...</span> | |
| <span className="text-[10px] font-extrabold text-primary/40 uppercase tracking-wider">Генерація графів</span> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2 bg-[#f4ebd0]/30 border border-primary/10 px-3 py-2 rounded-xl"> | |
| <Loader2 className="animate-spin text-amber-600" size={14} /> | |
| <span className="text-xs font-body italic text-primary/75">{loadingSteps[loadingStepIndex]}</span> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| {/* Suggestions chips (only visible if not loading) */} | |
| {!aiLoading && chatMessages.length === 1 && ( | |
| <div className="flex flex-col gap-2 mb-4 bg-primary/5 p-3 rounded-2xl border-2 border-dashed border-primary/10"> | |
| <span className="text-[10px] font-extrabold uppercase text-primary/60 tracking-wider flex items-center gap-1.5"> | |
| <Sparkles size={12} className="text-amber-500" /> | |
| <span>Підказки для старту</span> | |
| </span> | |
| <div className="flex flex-wrap gap-2"> | |
| {suggestions.map((sug, idx) => ( | |
| <button | |
| key={idx} | |
| onClick={() => setPrompt(sug.text)} | |
| className="flex items-center gap-1.5 px-3 py-1.5 bg-[#faf7f2] hover:bg-[#faf7f2]/80 border border-primary/20 hover:border-primary rounded-xl text-xs font-bold text-primary transition-all cursor-pointer shadow-[1px_1px_0px_0px_rgba(0,0,0,0.1)] hover:scale-[1.02]" | |
| > | |
| {sug.icon} | |
| <span>{sug.tag}</span> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Chat Input Form */} | |
| <form onSubmit={handleSubmit} className="mt-auto"> | |
| <div className="relative border-2 border-primary rounded-2xl bg-[#faf7f2] shadow-[3px_3px_0px_0px_var(--color-primary)] flex items-center p-1 group focus-within:shadow-[4px_4px_0px_0px_var(--color-primary)] transition-all"> | |
| <textarea | |
| rows={1} | |
| value={prompt} | |
| onChange={(e) => setPrompt(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| disabled={aiLoading} | |
| placeholder={t('canvas.ask_ai_placeholder', 'Спроси стратега...')} | |
| className="flex-grow pl-3 pr-12 py-2 bg-transparent text-sm text-primary font-body outline-none resize-none max-h-24 scrollbar-hide placeholder:text-primary/40 placeholder:font-bold disabled:opacity-50" | |
| /> | |
| <button | |
| type="submit" | |
| disabled={!prompt.trim() || aiLoading} | |
| className="absolute right-2 top-1/2 -translate-y-1/2 p-2 bg-primary text-white rounded-xl hover:scale-105 active:scale-95 disabled:scale-100 disabled:opacity-35 disabled:cursor-not-allowed transition-all cursor-pointer flex items-center justify-center" | |
| > | |
| <Send size={14} /> | |
| </button> | |
| </div> | |
| </form> | |
| </div> | |
| </motion.div> | |
| </> | |
| )} | |
| </AnimatePresence> | |
| ); | |
| }; | |
| export default AIHintOverlay; | |