FlowWeb / src /features /canvas /components /AIHintOverlay.jsx
danylokhodus's picture
feat: rebrand QuestboardAI -> TaskFlow everywhere (UI, locales, index.html)
abb5ee1
Raw
History Blame Contribute Delete
19.6 kB
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;