| import React, { useRef } from 'react'; |
| import { Note, NoteType, GenerationStatus, Attachment } from '../types'; |
| import { LoadingSpinner } from './LoadingSpinner'; |
| import ReactMarkdown from 'react-markdown'; |
| import remarkGfm from 'remark-gfm'; |
| import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; |
| import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; |
|
|
| interface NoteCardProps { |
| note: Note; |
| isRoot?: boolean; |
| onRetry?: (noteId: string) => void; |
| onExport?: () => void; |
| isExporting?: boolean; |
| onUpdate?: (noteId: string, updates: Partial<Note> | string) => void; |
| onDelete?: (noteId: string) => void; |
| onRegenerateImage?: (noteId: string, feedback: string) => void; |
| onAddAttachment?: (noteId: string, attachment: Attachment) => void; |
| onRemoveAttachment?: (noteId: string, attachmentId: string) => void; |
| onAddTag?: (noteId: string, tag: string) => void; |
| onRemoveTag?: (noteId: string, tag: string) => void; |
| getNoteTitle?: (noteId: string) => string; |
| onNavigateToNote?: (noteId: string) => void; |
| dragHandleProps?: any; |
| } |
|
|
| const NoteCard: React.FC<NoteCardProps> = ({ |
| note, |
| isRoot = false, |
| onRetry, |
| onExport, |
| isExporting = false, |
| onUpdate, |
| onDelete, |
| onRegenerateImage, |
| onAddAttachment, |
| onRemoveAttachment, |
| onAddTag, |
| onRemoveTag, |
| getNoteTitle, |
| onNavigateToNote, |
| dragHandleProps |
| }) => { |
| const [isEditing, setIsEditing] = React.useState(false); |
| const [editContent, setEditContent] = React.useState(note.content); |
| const [feedback, setFeedback] = React.useState(''); |
| const [showFeedback, setShowFeedback] = React.useState(false); |
| const [useFeedback, setUseFeedback] = React.useState(false); |
| const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); |
| const [showSuccess, setShowSuccess] = React.useState(false); |
| const fileInputRef = useRef<HTMLInputElement>(null); |
|
|
| const isImage = note.type === NoteType.IMAGE; |
| const isLoading = note.status === GenerationStatus.GENERATING; |
| const isIdle = note.status === GenerationStatus.IDLE; |
| const isError = note.status === GenerationStatus.ERROR; |
|
|
| const handleSave = () => { |
| if (onUpdate) { |
| onUpdate(note.id, { content: editContent }); |
| } |
| setIsEditing(false); |
| setShowSuccess(true); |
| setTimeout(() => setShowSuccess(false), 2500); |
| }; |
|
|
| const toggleTask = () => { |
| if (onUpdate) { |
| onUpdate(note.id, { isTask: !note.isTask }); |
| } |
| }; |
|
|
| const toggleTaskStatus = () => { |
| if (onUpdate) { |
| onUpdate(note.id, { isCompleted: !note.isCompleted }); |
| } |
| }; |
|
|
| const handleCancel = () => { |
| setEditContent(note.content); |
| setIsEditing(false); |
| }; |
|
|
| const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
| const file = e.target.files?.[0]; |
| if (!file || !onAddAttachment) return; |
|
|
| const reader = new FileReader(); |
| reader.onloadend = () => { |
| const base64String = reader.result as string; |
| let type: 'image' | 'audio' | 'file' = 'file'; |
| if (file.type.startsWith('image/')) type = 'image'; |
| else if (file.type.startsWith('audio/')) type = 'audio'; |
|
|
| const newAttachment: Attachment = { |
| id: Date.now().toString(), |
| type, |
| url: base64String, |
| name: file.name |
| }; |
| onAddAttachment(note.id, newAttachment); |
| }; |
| reader.readAsDataURL(file); |
| if (fileInputRef.current) fileInputRef.current.value = ''; |
| }; |
|
|
| const renderAttachments = () => { |
| if (!note.attachments || note.attachments.length === 0) return null; |
|
|
| return ( |
| <div className="mt-4 space-y-2 border-t border-slate-700/50 pt-4"> |
| <h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Attachments</h4> |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> |
| {note.attachments.map(attachment => ( |
| <div key={attachment.id} className="relative group/attach bg-slate-900/50 border border-slate-700 rounded-lg p-3 flex items-center gap-3"> |
| {attachment.type === 'image' && ( |
| <div className="w-10 h-10 rounded overflow-hidden shrink-0 bg-slate-800"> |
| <img src={attachment.url} alt={attachment.name} className="w-full h-full object-cover" /> |
| </div> |
| )} |
| {attachment.type === 'audio' && ( |
| <div className="w-10 h-10 rounded shrink-0 bg-indigo-900/30 text-indigo-400 flex items-center justify-center"> |
| 🎵 |
| </div> |
| )} |
| {attachment.type === 'file' && ( |
| <div className="w-10 h-10 rounded shrink-0 bg-slate-800 text-slate-400 flex items-center justify-center"> |
| 📄 |
| </div> |
| )} |
| |
| <div className="flex-1 min-w-0"> |
| <p className="text-sm text-slate-300 truncate" title={attachment.name}>{attachment.name}</p> |
| {attachment.type === 'audio' && ( |
| <audio src={attachment.url} controls className="w-full h-6 mt-1" /> |
| )} |
| {attachment.type === 'file' && ( |
| <a href={attachment.url} download={attachment.name} className="text-xs text-indigo-400 hover:underline">Download</a> |
| )} |
| </div> |
| |
| {onRemoveAttachment && ( |
| <button |
| onClick={() => onRemoveAttachment(note.id, attachment.id)} |
| className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs opacity-0 group-hover/attach:opacity-100 transition-opacity shadow-lg hide-in-pdf" |
| title="Remove Attachment" |
| > |
| × |
| </button> |
| )} |
| </div> |
| ))} |
| </div> |
| </div> |
| ); |
| }; |
|
|
| const renderContent = () => { |
| if (isLoading) { |
| return ( |
| <div className="flex flex-col items-center justify-center p-12 space-y-4 text-slate-400 bg-slate-900/30 rounded-lg border border-dashed border-slate-700"> |
| <LoadingSpinner /> |
| <span className="text-sm animate-pulse text-indigo-400 font-medium">Preparing content...</span> |
| <p className="text-xs max-w-xs text-center opacity-60">AI is detailing this step, please wait.</p> |
| </div> |
| ); |
| } |
|
|
| if (isIdle) { |
| return ( |
| <div className="p-6 bg-slate-900/20 rounded-lg border border-dashed border-slate-700 text-slate-500 italic text-sm"> |
| {note.content || "Content will be generated when it's time for this step..."} |
| </div> |
| ) |
| } |
|
|
| if (isError) { |
| return ( |
| <div className="flex flex-col items-center justify-center p-6 gap-3 border border-red-900/50 bg-red-900/10 rounded-lg text-center"> |
| <p className="text-red-400">An error occurred while generating content.</p> |
| {onRetry && ( |
| <button |
| onClick={() => onRetry(note.id)} |
| className="px-4 py-2 bg-red-800/50 hover:bg-red-700/50 text-red-200 text-sm rounded-md transition-colors border border-red-700/50 flex items-center gap-2" |
| > |
| <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"> |
| <path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v3.292a1 1 0 01-2 0V12.899a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" /> |
| </svg> |
| Retry |
| </button> |
| )} |
| </div> |
| ); |
| } |
|
|
| if (isEditing) { |
| return ( |
| <div className="flex flex-col gap-4 bg-slate-900/50 p-4 rounded-xl border border-indigo-500/30"> |
| <textarea |
| value={editContent} |
| onChange={(e) => setEditContent(e.target.value)} |
| className="w-full bg-slate-950 text-slate-200 p-5 rounded-lg border border-slate-700 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 focus:outline-none min-h-[250px] font-mono text-sm leading-relaxed shadow-inner" |
| placeholder="Write note content here..." |
| /> |
| <div className="flex justify-end gap-3"> |
| <button |
| onClick={handleCancel} |
| className="px-5 py-2.5 text-sm font-medium text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-lg transition-colors border border-slate-600" |
| > |
| Cancel |
| </button> |
| <button |
| onClick={handleSave} |
| className="px-5 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-colors shadow-lg shadow-indigo-500/20 flex items-center gap-2" |
| > |
| <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"> |
| <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /> |
| </svg> |
| Save |
| </button> |
| </div> |
| </div> |
| ); |
| } |
|
|
| if (isImage) { |
| if (note.content && note.content.startsWith('data:image')) { |
| return ( |
| <div className="flex flex-col items-center p-4 bg-slate-950 rounded-lg border border-slate-800"> |
| <img src={note.content} alt={note.title} className="max-w-full h-auto rounded shadow-2xl border border-slate-700" /> |
| <p className="text-xs text-slate-500 mt-2 italic">Generated by Gemini Image</p> |
| |
| <div className="w-full mt-6 pt-6 border-t border-slate-800"> |
| <div className="flex gap-4 max-w-md mx-auto"> |
| <label className="flex items-center gap-2 text-sm text-slate-300 cursor-pointer hover:text-white transition-colors"> |
| <input |
| type="checkbox" |
| checked={useFeedback} |
| onChange={(e) => setUseFeedback(e.target.checked)} |
| className="rounded border-slate-600 text-indigo-500 focus:ring-indigo-500 bg-slate-800" |
| /> |
| Revise image with feedback |
| </label> |
| |
| {useFeedback && ( |
| <textarea |
| value={feedback} |
| onChange={(e) => setFeedback(e.target.value)} |
| placeholder="Ex: Darker atmosphere, make the character's hair blue..." |
| className="w-full bg-slate-900 text-slate-300 p-3 rounded-lg border border-slate-700 text-xs focus:outline-none focus:border-indigo-500 transition-colors" |
| rows={3} |
| /> |
| )} |
| |
| <button |
| onClick={() => { |
| if (onRegenerateImage) onRegenerateImage(note.id, useFeedback ? feedback : ''); |
| setFeedback(''); |
| setUseFeedback(false); |
| }} |
| className="w-full bg-indigo-600/20 hover:bg-indigo-600/30 text-indigo-300 border border-indigo-500/30 py-2 rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2" |
| > |
| <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"> |
| <path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v3.292a1 1 0 01-2 0V12.899a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" /> |
| </svg> |
| Regenerate |
| </button> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
| return ( |
| <div className="p-10 bg-[#0B0F19]/40 rounded-[2rem] border border-dashed border-white/10 text-center space-y-6"> |
| <div className="w-20 h-20 bg-orange-500/10 rounded-[1.5rem] flex items-center justify-center mx-auto text-orange-400"> |
| <svg xmlns="http://www.w3.org/2000/svg" className="h-10 w-10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg> |
| </div> |
| <div className="space-y-2"> |
| <h4 className="text-white font-black text-xs uppercase tracking-[0.2em]">Image Generation Failed</h4> |
| <p className="text-slate-500 text-xs leading-relaxed max-w-sm mx-auto"> |
| {note.content || "An unknown error occurred."} |
| </p> |
| </div> |
| {onRetry && ( |
| <button |
| onClick={() => onRetry(note.id)} |
| className="bg-white/5 border border-white/10 hover:border-white/20 text-indigo-400 hover:text-white px-6 py-3 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all" |
| > |
| Retry |
| </button> |
| )} |
| </div> |
| ); |
| } |
|
|
| |
| return ( |
| <div |
| className="prose prose-invert prose-slate max-w-none group/content relative" |
| > |
| {!isRoot && note.type !== NoteType.IMAGE && ( |
| <button |
| onClick={() => setIsEditing(true)} |
| className="absolute -right-2 -top-2 opacity-0 group-hover/content:opacity-100 transition-opacity bg-indigo-600 text-white text-[10px] px-2 py-1 rounded shadow-lg z-10 hide-in-pdf" |
| > |
| Click to edit |
| </button> |
| )} |
| <div className="text-slate-300 leading-7 font-light markdown-body"> |
| <ReactMarkdown |
| remarkPlugins={[remarkGfm]} |
| components={{ |
| code({node, inline, className, children, ...props}: any) { |
| const match = /language-(\w+)/.exec(className || '') |
| return !inline && match ? ( |
| <SyntaxHighlighter |
| {...props} |
| children={String(children).replace(/\n$/, '')} |
| style={vscDarkPlus} |
| language={match[1]} |
| PreTag="div" |
| className="rounded-xl border border-white/10 my-6 text-sm shadow-lg shadow-black/20" |
| /> |
| ) : ( |
| <code {...props} className={`${className} bg-white/5 text-indigo-300 px-1.5 py-0.5 rounded-md text-sm border border-white/5`}> |
| {children} |
| </code> |
| ) |
| }, |
| img({node, ...props}: any) { |
| if (!props.src) return null; |
| return ( |
| <span className="my-8 flex justify-center"> |
| <img {...props} src={props.src} className="max-w-full h-auto rounded-xl shadow-2xl shadow-black/40 border border-white/10" /> |
| </span> |
| ) |
| }, |
| h1: ({node, ...props}: any) => <h1 {...props} className="text-2xl md:text-3xl font-bold text-white mt-8 mb-6 tracking-tight" />, |
| h2: ({node, ...props}: any) => <h2 {...props} className="text-xl md:text-2xl font-bold text-indigo-200 mt-8 mb-4 tracking-tight" />, |
| h3: ({node, ...props}: any) => <h3 {...props} className="text-lg md:text-xl font-bold text-indigo-300 mt-6 mb-3 pb-2 border-b border-white/5" />, |
| hr: ({node, ...props}: any) => <hr {...props} className="my-8 border-white/5" />, |
| blockquote: ({node, ...props}: any) => <blockquote {...props} className="border-l-4 border-indigo-500/50 pl-5 italic text-slate-400 my-6 bg-white/[0.02] py-3 rounded-r-lg" />, |
| a: ({node, ...props}: any) => <a {...props} className="text-indigo-400 hover:text-indigo-300 hover:underline decoration-indigo-500/30 underline-offset-4 transition-colors" target="_blank" rel="noopener noreferrer" />, |
| ul: ({node, ...props}: any) => <ul {...props} className="list-disc pl-6 mb-6 space-y-2 marker:text-indigo-500/50" />, |
| ol: ({node, ...props}: any) => <ol {...props} className="list-decimal pl-6 mb-6 space-y-2 marker:text-indigo-500/50" />, |
| li: ({node, ...props}: any) => <li {...props} className="text-slate-300 pl-1" />, |
| p: ({node, children, ...props}: any) => { |
| const hasImage = node?.children?.some((child: any) => child.tagName === 'img'); |
| if (hasImage) { |
| return <div {...props} className="mb-4">{children}</div>; |
| } |
| return <p {...props} className="mb-4">{children}</p>; |
| } |
| }} |
| > |
| {note.content} |
| </ReactMarkdown> |
| </div> |
| </div> |
| ); |
| }; |
|
|
| const [showTagInput, setShowTagInput] = React.useState(false); |
| const [newTagText, setNewTagText] = React.useState(''); |
|
|
| const handleAddTagSubmit = () => { |
| if (newTagText.trim() && onAddTag) { |
| onAddTag(note.id, newTagText.trim()); |
| setNewTagText(''); |
| setShowTagInput(false); |
| } |
| }; |
|
|
| const renderTags = () => { |
| return ( |
| <div className="mt-4 flex flex-wrap items-center gap-2 border-t border-slate-700/50 pt-4"> |
| <span className="text-xs font-semibold text-slate-400 uppercase tracking-wider mr-2">Tags:</span> |
| {(note.tags || []).map(tag => ( |
| <span key={tag} className="bg-indigo-900/40 text-indigo-300 text-xs px-2 py-1 rounded-md flex items-center gap-1 border border-indigo-500/30"> |
| #{tag} |
| {onRemoveTag && ( |
| <button onClick={() => onRemoveTag(note.id, tag)} className="hover:text-red-400 ml-1 hide-in-pdf">×</button> |
| )} |
| </span> |
| ))} |
| {onAddTag && ( |
| <div className="flex items-center gap-2"> |
| {showTagInput ? ( |
| <div className="flex items-center gap-2 animate-fadeIn"> |
| <input |
| type="text" |
| autoFocus |
| value={newTagText} |
| onChange={(e) => setNewTagText(e.target.value)} |
| onKeyDown={(e) => { |
| if (e.key === 'Enter') handleAddTagSubmit(); |
| if (e.key === 'Escape') { setShowTagInput(false); setNewTagText(''); } |
| }} |
| placeholder="Tag name..." |
| className="bg-slate-900 border border-indigo-500/50 rounded-md px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500" |
| /> |
| <button onClick={handleAddTagSubmit} className="text-emerald-400 hover:text-emerald-300"> |
| <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"> |
| <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /> |
| </svg> |
| </button> |
| <button onClick={() => setShowTagInput(false)} className="text-slate-500 hover:text-slate-300">×</button> |
| </div> |
| ) : ( |
| <button |
| onClick={() => setShowTagInput(true)} |
| className="text-xs text-slate-500 hover:text-indigo-400 border border-dashed border-slate-600 rounded-md px-2 py-1 hide-in-pdf" |
| > |
| + Tag |
| </button> |
| )} |
| </div> |
| )} |
| </div> |
| ); |
| }; |
|
|
| const [linkedNotesSearch, setLinkedNotesSearch] = React.useState(''); |
|
|
| const renderLinkedNotes = () => { |
| if (!note.linkedNoteIds || note.linkedNoteIds.length === 0 || !getNoteTitle) return null; |
| |
| const filteredLinkedNotes = note.linkedNoteIds.filter(id => { |
| const title = getNoteTitle(id); |
| return title && title.toLowerCase().includes(linkedNotesSearch.toLowerCase()); |
| }); |
|
|
| return ( |
| <div className="mt-4 space-y-3 border-t border-slate-700/50 pt-4"> |
| <div className="flex items-center justify-between"> |
| <h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Linked Notes</h4> |
| {note.linkedNoteIds.length > 3 && ( |
| <div className="relative w-48"> |
| <input |
| type="text" |
| placeholder="Search links..." |
| value={linkedNotesSearch} |
| onChange={(e) => setLinkedNotesSearch(e.target.value)} |
| className="w-full bg-slate-900 text-slate-300 text-[10px] rounded pl-6 pr-2 py-1 border border-slate-700 focus:border-indigo-500 focus:outline-none" |
| /> |
| <svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 absolute left-2 top-1.5 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> |
| </svg> |
| </div> |
| )} |
| </div> |
| <div className="flex flex-wrap gap-2"> |
| {filteredLinkedNotes.length > 0 ? filteredLinkedNotes.map(linkedNoteId => { |
| const title = getNoteTitle(linkedNoteId); |
| if (!title) return null; |
| return ( |
| <button |
| key={linkedNoteId} |
| onClick={() => onNavigateToNote && onNavigateToNote(linkedNoteId)} |
| className="bg-slate-800 border border-slate-700 rounded-md px-3 py-1.5 text-sm text-indigo-300 flex items-center gap-2 hover:bg-slate-700 hover:border-indigo-500 transition-colors cursor-pointer" |
| > |
| <svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3" viewBox="0 0 20 20" fill="currentColor"> |
| <path fillRule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clipRule="evenodd" /> |
| </svg> |
| {title} |
| </button> |
| ); |
| }) : ( |
| <p className="text-xs text-slate-500 italic">No matching links found.</p> |
| )} |
| </div> |
| </div> |
| ); |
| }; |
|
|
| const getIcon = () => { |
| switch (note.type) { |
| case NoteType.IMAGE: return '🎨'; |
| case NoteType.CODE: return '💻'; |
| case NoteType.ROOT: return '📁'; |
| default: return '📝'; |
| } |
| }; |
|
|
| const handleCopy = () => { |
| if(note.type === NoteType.IMAGE) return; |
| navigator.clipboard.writeText(note.content); |
| }; |
|
|
| const handleExportNoteMD = () => { |
| if (note.type === NoteType.IMAGE) return; |
| const blob = new Blob([note.content], { type: 'text/markdown' }); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = `${note.title.replace(/\s+/g, '_')}.md`; |
| document.body.appendChild(a); |
| a.click(); |
| document.body.removeChild(a); |
| URL.revokeObjectURL(url); |
| }; |
|
|
| return ( |
| <div className={` |
| relative group transition-all duration-700 break-inside-avoid |
| ${isRoot |
| ? 'bg-gradient-to-br from-[#1A1D26] to-[#0F1117] border-indigo-500/40 border-2 rounded-[2.5rem] shadow-2xl shadow-black/60' |
| : 'bg-[#161B26]/30 backdrop-blur-xl border border-white/5 rounded-[2rem] hover:border-indigo-500/30 hover:bg-[#161B26]/50 shadow-xl shadow-black/30 transition-all duration-500' |
| } |
| `}> |
| {/* Header */} |
| <div |
| className={`flex flex-col sm:flex-row sm:items-center justify-between px-6 py-5 md:px-8 md:py-6 border-b ${isRoot ? 'border-white/10' : 'border-white/5'} gap-4`} |
| > |
| <div className="flex items-center gap-5 min-w-0"> |
| {dragHandleProps && ( |
| <div |
| {...dragHandleProps} |
| className="cursor-grab active:cursor-grabbing text-slate-600 hover:text-slate-300 p-2.5 -ml-3 rounded-2xl hover:bg-white/5 hide-in-pdf transition-all duration-300" |
| title="Drag" |
| > |
| <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8h16M4 16h16" /> |
| </svg> |
| </div> |
| )} |
| <div className={`w-12 h-12 rounded-xl flex items-center justify-center shrink-0 transition-transform duration-500 group-hover:scale-105 ${isRoot ? 'bg-indigo-500 text-white shadow-xl shadow-indigo-500/30' : 'bg-[#1F2937]/50 text-slate-400 border border-white/5'}`}> |
| <span className="text-2xl">{getIcon()}</span> |
| </div> |
| <div className="min-w-0"> |
| <div className="flex items-center gap-4"> |
| {note.isTask && ( |
| <button |
| onClick={(e) => { e.stopPropagation(); toggleTaskStatus(); }} |
| className={`w-6 h-6 rounded-lg border-2 flex items-center justify-center transition-all shrink-0 ${note.isCompleted ? 'bg-indigo-500 border-indigo-500 text-white' : 'border-slate-700 hover:border-indigo-500'}`} |
| > |
| {note.isCompleted && ( |
| <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"> |
| <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /> |
| </svg> |
| )} |
| </button> |
| )} |
| <h3 |
| title={note.title} |
| className={`font-black tracking-tight truncate ${isRoot ? 'text-2xl md:text-3xl text-white' : 'text-lg md:text-xl text-slate-100'} ${note.isCompleted ? 'line-through opacity-50' : ''}`} |
| > |
| {note.title} |
| </h3> |
| {showSuccess && ( |
| <span className="px-3 py-1 bg-emerald-500/10 text-emerald-400 text-[10px] font-black rounded-full uppercase tracking-[0.2em] animate-pulse border border-emerald-500/20"> |
| Saved |
| </span> |
| )} |
| </div> |
| <div className="flex items-center gap-3 mt-1.5"> |
| {isRoot && <span className="text-[10px] text-indigo-400 font-black tracking-[0.3em] uppercase">Master Project</span>} |
| {!isRoot && <span className="text-[10px] text-slate-500 font-black tracking-[0.3em] uppercase">Step {note.status === GenerationStatus.COMPLETED ? 'Complete' : 'Pending'}</span>} |
| <span className="w-1.5 h-1.5 rounded-full bg-slate-800"></span> |
| <span className="text-[10px] text-slate-600 font-black uppercase tracking-[0.2em]">{new Date(note.timestamp).toLocaleDateString('en-US', { day: 'numeric', month: 'short' })}</span> |
| {note.assignedAgent && ( |
| <> |
| <span className="w-1.5 h-1.5 rounded-full bg-indigo-500/50"></span> |
| <div className="flex items-center gap-1.5 px-2 py-0.5 bg-indigo-500/10 border border-indigo-500/20 rounded-md"> |
| <span className="text-[9px] text-indigo-400 font-black uppercase tracking-wider">Agent: {note.assignedAgent}</span> |
| {note.agentRole && ( |
| <span className="text-[9px] text-slate-500 font-medium italic">({note.agentRole})</span> |
| )} |
| </div> |
| </> |
| )} |
| </div> |
| </div> |
| </div> |
| |
| <div className="flex flex-wrap gap-2 items-center justify-start sm:justify-end hide-in-pdf sm:ml-auto shrink-0"> |
| {isRoot && onExport && !isLoading && ( |
| <button |
| onClick={onExport} |
| disabled={isExporting} |
| className={`flex items-center gap-2 px-4 py-2 md:px-6 md:py-3 rounded-xl md:rounded-2xl text-[10px] md:text-[11px] font-black uppercase tracking-[0.1em] md:tracking-[0.2em] transition-all shadow-xl ${isExporting ? 'bg-indigo-800 text-indigo-300 cursor-not-allowed shadow-none' : 'bg-indigo-600 hover:bg-indigo-500 text-white shadow-indigo-600/30 hover:-translate-y-0.5 active:translate-y-0'}`} |
| title="Download Project" |
| > |
| {isExporting ? ( |
| <svg className="animate-spin h-4 w-4 md:h-5 md:w-5 text-indigo-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| ) : ( |
| <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 md:h-5 md:w-5" viewBox="0 0 20 20" fill="currentColor"> |
| <path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" /> |
| </svg> |
| )} |
| <span className="hidden xs:inline">{isExporting ? '...' : 'Export'}</span> |
| {!isExporting && <span className="xs:hidden">EXP</span>} |
| </button> |
| )} |
| |
| {note.type !== NoteType.IMAGE && note.content && !isLoading && !isIdle && !isError && ( |
| <> |
| <button onClick={handleCopy} className="opacity-0 group-hover:opacity-100 transition-all text-[10px] uppercase tracking-[0.2em] bg-white/5 hover:bg-indigo-600 text-slate-400 hover:text-white px-4 py-2.5 rounded-2xl font-black border border-white/5 shadow-lg"> |
| Copy |
| </button> |
| <button onClick={handleExportNoteMD} className="opacity-0 group-hover:opacity-100 transition-all text-[10px] uppercase tracking-[0.2em] bg-white/5 hover:bg-indigo-600 text-slate-400 hover:text-white px-4 py-2.5 rounded-2xl font-black border border-white/5 shadow-lg"> |
| MD |
| </button> |
| </> |
| )} |
| |
| {!isRoot && onDelete && ( |
| <div className="relative opacity-0 group-hover:opacity-100 transition-all flex items-center"> |
| {showDeleteConfirm ? ( |
| <div className="flex items-center gap-2 bg-red-500/10 border border-red-500/20 rounded-2xl p-1.5"> |
| <button onClick={(e) => { e.stopPropagation(); onDelete(note.id); }} className="text-[10px] uppercase tracking-[0.2em] bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-xl font-black transition-all shadow-lg">Delete</button> |
| <button onClick={(e) => { e.stopPropagation(); setShowDeleteConfirm(false); }} className="text-[10px] uppercase tracking-[0.2em] bg-slate-800 hover:bg-slate-700 text-slate-300 px-4 py-2 rounded-xl font-black transition-all">Cancel</button> |
| </div> |
| ) : ( |
| <button |
| onPointerDown={(e) => e.stopPropagation()} |
| onClick={(e) => { e.stopPropagation(); setShowDeleteConfirm(true); }} |
| className="p-3 text-slate-600 hover:text-red-400 hover:bg-red-500/10 rounded-2xl transition-all duration-300" |
| title="Delete Step" |
| > |
| <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> |
| <path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" /> |
| </svg> |
| </button> |
| )} |
| </div> |
| )} |
| </div> |
| </div> |
|
|
| {} |
| <div className={`px-6 py-6 md:px-10 md:py-8 ${isRoot ? 'bg-white/[0.01]' : ''}`}> |
| {renderContent()} |
| {renderAttachments()} |
| {renderTags()} |
| {renderLinkedNotes()} |
| </div> |
| |
| {} |
| <div className="px-6 py-4 md:px-8 md:py-5 border-t border-white/5 bg-black/40 flex justify-between items-center hide-in-pdf rounded-b-[2rem]"> |
| <div className="flex items-center gap-6"> |
| {!isRoot && onUpdate && ( |
| <button |
| onClick={toggleTask} |
| className={`text-[11px] font-black uppercase tracking-[0.2em] flex items-center gap-3 transition-all group/footer ${note.isTask ? 'text-indigo-400' : 'text-slate-500 hover:text-indigo-400'}`} |
| > |
| <div className={`w-8 h-8 rounded-xl flex items-center justify-center transition-all duration-300 ${note.isTask ? 'bg-indigo-500/20' : 'bg-white/5 group-hover/footer:bg-indigo-500/10'}`}> |
| <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"> |
| <path fillRule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" /> |
| </svg> |
| </div> |
| {note.isTask ? 'Task' : 'Convert to Task'} |
| </button> |
| )} |
| {onAddAttachment && ( |
| <div> |
| <input |
| type="file" |
| ref={fileInputRef} |
| onChange={handleFileChange} |
| className="hidden" |
| accept="image/*,audio/*,.pdf,.doc,.docx,.txt" |
| /> |
| <button |
| onClick={() => fileInputRef.current?.click()} |
| className="text-[11px] font-black uppercase tracking-[0.2em] flex items-center gap-3 text-slate-500 hover:text-indigo-400 transition-all group/footer" |
| > |
| <div className="w-8 h-8 rounded-xl bg-white/5 flex items-center justify-center group-hover/footer:bg-indigo-500/10 transition-all duration-300"> |
| <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"> |
| <path fillRule="evenodd" d="M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" clipRule="evenodd" /> |
| </svg> |
| </div> |
| Add File |
| </button> |
| </div> |
| )} |
| {onAddTag && ( |
| <button |
| onClick={() => { |
| setShowTagInput(true); |
| setTimeout(() => { |
| const noteEl = document.getElementById(`note-${note.id}`); |
| if (noteEl) noteEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
| }, 100); |
| }} |
| className="text-[11px] font-black uppercase tracking-[0.2em] flex items-center gap-3 text-slate-500 hover:text-indigo-400 transition-all group/footer" |
| > |
| <div className="w-8 h-8 rounded-xl bg-white/5 flex items-center justify-center group-hover/footer:bg-indigo-500/10 transition-all duration-300"> |
| <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"> |
| <path fillRule="evenodd" d="M17.707 9.293l-5-5a1 1 0 00-1.414 0l-8 8a1 1 0 000 1.414l5 5a1 1 0 001.414 0l8-8a1 1 0 000-1.414zM9 14a1 1 0 11-2 0 1 1 0 012 0z" clipRule="evenodd" /> |
| </svg> |
| </div> |
| Tag |
| </button> |
| )} |
| </div> |
| |
| <div className="flex items-center gap-4"> |
| <div className="text-[10px] text-slate-700 font-black uppercase tracking-[0.2em]"> |
| ID: {note.id.split('-')[0]} |
| </div> |
| </div> |
| </div> |
|
|
| {} |
| {isRoot && ( |
| <div className="h-2 bg-gradient-to-r from-indigo-500 via-purple-600 to-indigo-500 w-full rounded-b-3xl opacity-30 blur-[1px]"></div> |
| )} |
| </div> |
| ); |
| }; |
|
|
| export default React.memo(NoteCard); |