feat: initial ChatUI modular con arbol de temas, chat n8n y KeyNotes editables
This commit is contained in:
commit
19aaeb8619
42 changed files with 6155 additions and 0 deletions
61
src/components/chat/ChatInput.tsx
Normal file
61
src/components/chat/ChatInput.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { useState, type KeyboardEvent } from 'react'
|
||||
import { useChat } from '../../hooks/useChat'
|
||||
|
||||
export function ChatInput({ disabled }: { disabled: boolean }) {
|
||||
const { send, stop, retry, isSending } = useChat()
|
||||
const [text, setText] = useState('')
|
||||
|
||||
function submit() {
|
||||
const v = text.trim()
|
||||
if (!v || isSending || disabled) return
|
||||
void send(v)
|
||||
setText('')
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={disabled}
|
||||
placeholder={disabled ? 'Selecciona un subtema para escribir…' : 'Escribe un mensaje (Enter para enviar, Shift+Enter para salto de línea)'}
|
||||
rows={2}
|
||||
className="w-full resize-none rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 disabled:opacity-50"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={retry}
|
||||
disabled={isSending || disabled}
|
||||
className="rounded px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-800 disabled:opacity-40"
|
||||
title="Reintentar último mensaje"
|
||||
>
|
||||
↻ Reintentar
|
||||
</button>
|
||||
{isSending ? (
|
||||
<button
|
||||
onClick={stop}
|
||||
className="rounded bg-red-600 px-3 py-1.5 text-xs text-white hover:bg-red-500"
|
||||
>
|
||||
■ Detener
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={!text.trim() || disabled}
|
||||
className="rounded bg-indigo-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-40"
|
||||
>
|
||||
Enviar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
15
src/components/chat/MarkdownRenderer.tsx
Normal file
15
src/components/chat/MarkdownRenderer.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
export function MarkdownRenderer({ children }: { children: string }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ node, ...props }) => <a {...props} target="_blank" rel="noreferrer noopener" />,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
}
|
||||
36
src/components/chat/MessageBubble.tsx
Normal file
36
src/components/chat/MessageBubble.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { Message } from '../../types'
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
import { TypingIndicator } from './TypingIndicator'
|
||||
|
||||
export function MessageBubble({ message, showTyping }: { message: Message; showTyping?: boolean }) {
|
||||
const isUser = message.role === 'user'
|
||||
const isError = message.status === 'error'
|
||||
|
||||
return (
|
||||
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
|
||||
isUser
|
||||
? 'bg-indigo-600 text-white'
|
||||
: isError
|
||||
? 'bg-red-950/40 text-red-200 ring-1 ring-red-700/50'
|
||||
: 'bg-slate-800 text-slate-100 ring-1 ring-slate-700/50'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2 text-[10px] uppercase tracking-wide opacity-70">
|
||||
{isUser ? 'Tú' : 'Asistente'}
|
||||
{isError && <span className="text-red-300">— error</span>}
|
||||
</div>
|
||||
{showTyping || (message.content === '' && message.status === 'pending') ? (
|
||||
<TypingIndicator />
|
||||
) : isUser ? (
|
||||
<p className="whitespace-pre-wrap break-words">{message.content}</p>
|
||||
) : (
|
||||
<div className="prose prose-sm prose-invert max-w-none break-words prose-pre:bg-slate-900 prose-pre:ring-1 prose-pre:ring-slate-700">
|
||||
<MarkdownRenderer>{message.content}</MarkdownRenderer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
src/components/chat/MessageList.tsx
Normal file
31
src/components/chat/MessageList.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import type { Message } from '../../types'
|
||||
import { MessageBubble } from './MessageBubble'
|
||||
import { TypingIndicator } from './TypingIndicator'
|
||||
|
||||
interface Props {
|
||||
messages: Message[]
|
||||
isSending: boolean
|
||||
}
|
||||
|
||||
export function MessageList({ messages, isSending }: Props) {
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||
}, [messages, isSending])
|
||||
|
||||
const lastAssistantPending =
|
||||
isSending && messages[messages.length - 1]?.role === 'assistant' && messages[messages.length - 1]?.status === 'pending'
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-3 p-4">
|
||||
{messages.map((m, i) => {
|
||||
const isPendingLast =
|
||||
lastAssistantPending && i === messages.length - 1
|
||||
return <MessageBubble key={m.id} message={m} showTyping={isPendingLast} />
|
||||
})}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
src/components/chat/TypingIndicator.tsx
Normal file
10
src/components/chat/TypingIndicator.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export function TypingIndicator() {
|
||||
return (
|
||||
<div className="flex items-center gap-1 py-1">
|
||||
<span className="h-2 w-2 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.3s]" />
|
||||
<span className="h-2 w-2 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.15s]" />
|
||||
<span className="h-2 w-2 animate-bounce rounded-full bg-slate-400" />
|
||||
<span className="ml-2 text-xs text-slate-400">escribiendo…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
src/components/keynotes/KeyNoteCard.tsx
Normal file
76
src/components/keynotes/KeyNoteCard.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useTreeStore } from '../../store/store'
|
||||
import type { KeyNote } from '../../types'
|
||||
import { KeyNoteEditor } from './KeyNoteEditor'
|
||||
|
||||
interface Props {
|
||||
subThemeId: string
|
||||
note: KeyNote
|
||||
}
|
||||
|
||||
const typeBadge: Record<KeyNote['type'], { label: string; cls: string }> = {
|
||||
summary: { label: 'Resumen', cls: 'bg-sky-900/60 text-sky-200 ring-sky-700/50' },
|
||||
todo: { label: 'To-Do', cls: 'bg-emerald-900/60 text-emerald-200 ring-emerald-700/50' },
|
||||
free: { label: 'Nota', cls: 'bg-slate-700/60 text-slate-200 ring-slate-600/50' },
|
||||
}
|
||||
|
||||
export function KeyNoteCard({ subThemeId, note }: Props) {
|
||||
const updateKeyNote = useTreeStore((s) => s.updateKeyNote)
|
||||
const deleteKeyNote = useTreeStore((s) => s.deleteKeyNote)
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: note.id,
|
||||
})
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
}
|
||||
|
||||
const badge = typeBadge[note.type]
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="rounded-lg border border-slate-700/70 bg-slate-900/70 p-3 shadow-sm"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab text-slate-600 hover:text-slate-300"
|
||||
title="Arrastrar para reordenar"
|
||||
>
|
||||
⠿
|
||||
</button>
|
||||
<input
|
||||
value={note.title}
|
||||
onChange={(e) => updateKeyNote(subThemeId, note.id, { title: e.target.value })}
|
||||
className="min-w-0 flex-1 rounded bg-transparent px-1 py-0.5 text-sm font-semibold text-slate-100 outline-none focus:bg-slate-800"
|
||||
/>
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] uppercase ring-1 ${badge.cls}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
{note.source === 'n8n' && (
|
||||
<span className="rounded bg-purple-900/50 px-1.5 py-0.5 text-[10px] uppercase text-purple-200 ring-1 ring-purple-700/40">
|
||||
n8n
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('¿Eliminar esta KeyNote?')) deleteKeyNote(subThemeId, note.id)
|
||||
}}
|
||||
className="text-slate-500 hover:text-red-300"
|
||||
title="Eliminar"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<KeyNoteEditor subThemeId={subThemeId} note={note} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
156
src/components/keynotes/KeyNoteEditor.tsx
Normal file
156
src/components/keynotes/KeyNoteEditor.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import { useTreeStore } from '../../store/store'
|
||||
import type { KeyNote } from '../../types'
|
||||
|
||||
interface Props {
|
||||
subThemeId: string
|
||||
note: KeyNote
|
||||
}
|
||||
|
||||
export function KeyNoteEditor({ subThemeId, note }: Props) {
|
||||
const updateKeyNote = useTreeStore((s) => s.updateKeyNote)
|
||||
|
||||
if (note.type === 'todo') {
|
||||
return <TodoEditor subThemeId={subThemeId} note={note} />
|
||||
}
|
||||
|
||||
return (
|
||||
<textarea
|
||||
value={note.content}
|
||||
onChange={(e) => updateKeyNote(subThemeId, note.id, { content: e.target.value })}
|
||||
placeholder={note.type === 'summary' ? 'Escribe el resumen…' : 'Escribe tu nota…'}
|
||||
rows={note.type === 'summary' ? 4 : 3}
|
||||
className="w-full resize-y rounded bg-slate-950/50 px-2 py-1.5 text-sm text-slate-200 outline-none ring-1 ring-slate-700/60 focus:ring-indigo-500"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TodoEditor({ subThemeId, note }: Props) {
|
||||
const addTodoItem = useTreeStore((s) => s.addTodoItem)
|
||||
const updateTodoItem = useTreeStore((s) => s.updateTodoItem)
|
||||
const deleteTodoItem = useTreeStore((s) => s.deleteTodoItem)
|
||||
const reorderTodoItems = useTreeStore((s) => s.reorderTodoItems)
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
|
||||
|
||||
function onDragEnd(e: DragEndEvent) {
|
||||
const { active, over } = e
|
||||
if (!over || active.id === over.id) return
|
||||
const items = note.items ?? []
|
||||
const ids = items.map((it) => it.id)
|
||||
const from = ids.indexOf(String(active.id))
|
||||
const to = ids.indexOf(String(over.id))
|
||||
if (from === -1 || to === -1) return
|
||||
const next = [...ids]
|
||||
const [moved] = next.splice(from, 1)
|
||||
next.splice(to, 0, moved)
|
||||
reorderTodoItems(subThemeId, note.id, next)
|
||||
}
|
||||
|
||||
const items = note.items ?? []
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext items={items.map((it) => it.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="flex flex-col gap-1">
|
||||
{items.map((it) => (
|
||||
<TodoRow
|
||||
key={it.id}
|
||||
subThemeId={subThemeId}
|
||||
noteId={note.id}
|
||||
itemId={it.id}
|
||||
text={it.text}
|
||||
done={it.done}
|
||||
onToggle={() => updateTodoItem(subThemeId, note.id, it.id, { done: !it.done })}
|
||||
onText={(t) => updateTodoItem(subThemeId, note.id, it.id, { text: t })}
|
||||
onDelete={() => deleteTodoItem(subThemeId, note.id, it.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
<button
|
||||
onClick={() => addTodoItem(subThemeId, note.id, 'Nueva tarea')}
|
||||
className="mt-2 w-full rounded border border-dashed border-slate-700 px-2 py-1 text-xs text-slate-400 hover:border-slate-500 hover:text-slate-200"
|
||||
>
|
||||
+ Añadir tarea
|
||||
</button>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
function TodoRow({
|
||||
subThemeId,
|
||||
noteId,
|
||||
itemId,
|
||||
text,
|
||||
done,
|
||||
onToggle,
|
||||
onText,
|
||||
onDelete,
|
||||
}: {
|
||||
subThemeId: string
|
||||
noteId: string
|
||||
itemId: string
|
||||
text: string
|
||||
done: boolean
|
||||
onToggle: () => void
|
||||
onText: (t: string) => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: itemId,
|
||||
})
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
}
|
||||
void subThemeId
|
||||
void noteId
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="group flex items-center gap-2 rounded px-1 py-1 hover:bg-slate-800/40">
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab text-slate-600 hover:text-slate-300"
|
||||
title="Arrastrar"
|
||||
>
|
||||
⠿
|
||||
</button>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={done}
|
||||
onChange={onToggle}
|
||||
className="h-4 w-4 accent-emerald-500"
|
||||
/>
|
||||
<input
|
||||
value={text}
|
||||
onChange={(e) => onText(e.target.value)}
|
||||
className={`min-w-0 flex-1 rounded bg-transparent px-1 py-0.5 text-sm outline-none focus:bg-slate-800 ${
|
||||
done ? 'text-slate-500 line-through' : 'text-slate-200'
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="text-slate-600 opacity-0 hover:text-red-300 group-hover:opacity-100"
|
||||
title="Eliminar tarea"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
src/components/keynotes/KeyNotesDropZone.tsx
Normal file
50
src/components/keynotes/KeyNotesDropZone.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import {
|
||||
useSensor,
|
||||
useSensors,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import { useTreeStore } from '../../store/store'
|
||||
import type { KeyNote } from '../../types'
|
||||
import { KeyNoteCard } from './KeyNoteCard'
|
||||
|
||||
interface Props {
|
||||
subThemeId: string
|
||||
notes: KeyNote[]
|
||||
}
|
||||
|
||||
export function KeyNotesDropZone({ subThemeId, notes }: Props) {
|
||||
const reorderKeyNotes = useTreeStore((s) => s.reorderKeyNotes)
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }))
|
||||
|
||||
function onDragEnd(e: DragEndEvent) {
|
||||
const { active, over } = e
|
||||
if (!over || active.id === over.id) return
|
||||
const ids = notes.map((n) => n.id)
|
||||
const from = ids.indexOf(String(active.id))
|
||||
const to = ids.indexOf(String(over.id))
|
||||
if (from === -1 || to === -1) return
|
||||
const next = [...ids]
|
||||
const [moved] = next.splice(from, 1)
|
||||
next.splice(to, 0, moved)
|
||||
reorderKeyNotes(subThemeId, next)
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext items={notes.map((n) => n.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{notes.map((n) => (
|
||||
<KeyNoteCard key={n.id} subThemeId={subThemeId} note={n} />
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
52
src/components/layout/ChatPanel.tsx
Normal file
52
src/components/layout/ChatPanel.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { MessageList } from '../chat/MessageList'
|
||||
import { ChatInput } from '../chat/ChatInput'
|
||||
import { useTreeStore, selectActiveSubTheme, selectActiveMainTheme } from '../../store/store'
|
||||
import { useChat } from '../../hooks/useChat'
|
||||
|
||||
export function ChatPanel() {
|
||||
const activeSubTheme = useTreeStore(selectActiveSubTheme)
|
||||
const activeMainTheme = useTreeStore(selectActiveMainTheme)
|
||||
const { isSending, error, clearError } = useChat()
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<header className="flex items-center justify-between border-b border-slate-700/60 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-slate-100">
|
||||
{activeMainTheme?.title ?? '—'}
|
||||
</div>
|
||||
<div className="truncate text-xs text-slate-400">
|
||||
{activeSubTheme?.title ?? 'Selecciona o crea un subtema'}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{activeSubTheme ? (
|
||||
<MessageList messages={activeSubTheme.messages} isSending={isSending} />
|
||||
) : (
|
||||
<EmptyState />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mx-4 mb-2 flex items-start justify-between gap-2 rounded border border-red-700/60 bg-red-950/40 px-3 py-2 text-xs text-red-200">
|
||||
<span>{error}</span>
|
||||
<button onClick={clearError} className="text-red-300 hover:text-red-100">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-700/60 p-3">
|
||||
<ChatInput disabled={!activeSubTheme} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8 text-center text-sm text-slate-500">
|
||||
Crea un Tema y un Subtema en el panel izquierdo para empezar a chatear.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
68
src/components/layout/KeyNotesPanel.tsx
Normal file
68
src/components/layout/KeyNotesPanel.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import { useTreeStore, selectActiveSubTheme } from '../../store/store'
|
||||
import { KeyNotesDropZone } from '../keynotes/KeyNotesDropZone'
|
||||
|
||||
export function KeyNotesPanel() {
|
||||
const activeSubTheme = useTreeStore(selectActiveSubTheme)
|
||||
const addKeyNote = useTreeStore((s) => s.addKeyNote)
|
||||
|
||||
if (!activeSubTheme) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PanelHeader />
|
||||
<div className="flex flex-1 items-center justify-center p-6 text-center text-xs text-slate-500">
|
||||
Selecciona un subtema para ver y editar sus KeyNotes.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const actions = (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => addKeyNote(activeSubTheme.id, { type: 'summary', title: 'Nuevo Resumen' })}
|
||||
className="rounded px-2 py-1 text-[11px] text-slate-300 hover:bg-slate-800"
|
||||
title="Añadir resumen"
|
||||
>
|
||||
+ Resumen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => addKeyNote(activeSubTheme.id, { type: 'todo', title: 'Nuevo To-Do' })}
|
||||
className="rounded px-2 py-1 text-[11px] text-slate-300 hover:bg-slate-800"
|
||||
title="Añadir To-Do"
|
||||
>
|
||||
+ To-Do
|
||||
</button>
|
||||
<button
|
||||
onClick={() => addKeyNote(activeSubTheme.id, { type: 'free', title: 'Nueva Nota' })}
|
||||
className="rounded px-2 py-1 text-[11px] text-slate-300 hover:bg-slate-800"
|
||||
title="Añadir nota libre"
|
||||
>
|
||||
+ Nota
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PanelHeader right={actions} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<KeyNotesDropZone subThemeId={activeSubTheme.id} notes={activeSubTheme.keyNotes} />
|
||||
{activeSubTheme.keyNotes.length === 0 && (
|
||||
<div className="rounded border border-dashed border-slate-700 p-4 text-center text-xs text-slate-500">
|
||||
Sin KeyNotes. Crea una manualmente o pide a n8n que devuelva <code>keyNotes</code>.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PanelHeader({ right }: { right?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-slate-700/60 px-3 py-3">
|
||||
<h2 className="text-sm font-semibold text-slate-200">KeyNotes</h2>
|
||||
{right}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
15
src/components/layout/Sidebar.tsx
Normal file
15
src/components/layout/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { TreeView } from '../tree/TreeView'
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b border-slate-700/60">
|
||||
<h1 className="text-sm font-semibold tracking-wide text-slate-200">WebChatV2</h1>
|
||||
<span className="text-[10px] uppercase text-slate-500">Temas</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
<TreeView />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
95
src/components/tree/MainThemeNode.tsx
Normal file
95
src/components/tree/MainThemeNode.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { useState } from 'react'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
import { useTreeStore } from '../../store/store'
|
||||
import type { MainTheme } from '../../types'
|
||||
import { SubThemeNode } from './SubThemeNode'
|
||||
import { ThemeContextMenu } from './ThemeContextMenu'
|
||||
|
||||
export function MainThemeNode({ theme }: { theme: MainTheme }) {
|
||||
const toggleMainTheme = useTreeStore((s) => s.toggleMainTheme)
|
||||
const addSubTheme = useTreeStore((s) => s.addSubTheme)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: theme.id,
|
||||
data: { kind: 'main' },
|
||||
})
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="rounded border border-slate-800 bg-slate-900/60">
|
||||
<div className="flex items-center gap-1 px-2 py-2">
|
||||
<button
|
||||
onClick={() => toggleMainTheme(theme.id)}
|
||||
className="w-4 shrink-0 text-slate-400 hover:text-slate-200"
|
||||
title={theme.expanded ? 'Contraer' : 'Desplegar'}
|
||||
>
|
||||
{theme.expanded ? '▾' : '▸'}
|
||||
</button>
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab text-slate-600 hover:text-slate-300"
|
||||
title="Arrastrar para reordenar"
|
||||
>
|
||||
⠿
|
||||
</button>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-sm font-medium text-slate-100"
|
||||
onDoubleClick={() => setMenuOpen(true)}
|
||||
>
|
||||
{theme.title}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
className="text-slate-500 hover:text-slate-200"
|
||||
title="Opciones"
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
<button
|
||||
onClick={() => addSubTheme(theme.id)}
|
||||
className="text-slate-500 hover:text-slate-200"
|
||||
title="Añadir subtema"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{menuOpen && (
|
||||
<ThemeContextMenu
|
||||
mode="main"
|
||||
initialTitle={theme.title}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
onRename={(title) => useTreeStore.getState().renameMainTheme(theme.id, title)}
|
||||
onDelete={() => useTreeStore.getState().deleteMainTheme(theme.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{theme.expanded && (
|
||||
<div className="border-t border-slate-800 px-1 py-1">
|
||||
<SortableContext
|
||||
items={theme.subThemes.map((s) => s.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-col gap-1 pl-5">
|
||||
{theme.subThemes.map((s) => (
|
||||
<SubThemeNode key={s.id} sub={s} parentId={theme.id} />
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
{theme.subThemes.length === 0 && (
|
||||
<div className="pl-5 py-1 text-xs text-slate-600">Sin subtemas</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
67
src/components/tree/SubThemeNode.tsx
Normal file
67
src/components/tree/SubThemeNode.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { useState } from 'react'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useTreeStore } from '../../store/store'
|
||||
import type { SubTheme } from '../../types'
|
||||
import { ThemeContextMenu } from './ThemeContextMenu'
|
||||
|
||||
export function SubThemeNode({ sub, parentId }: { sub: SubTheme; parentId: string }) {
|
||||
const activeSubThemeId = useTreeStore((s) => s.activeSubThemeId)
|
||||
const setActiveSubTheme = useTreeStore((s) => s.setActiveSubTheme)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: sub.id,
|
||||
data: { kind: 'sub', parentId },
|
||||
})
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
}
|
||||
|
||||
const isActive = activeSubThemeId === sub.id
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`group flex items-center gap-1 rounded px-2 py-1.5 text-sm ${
|
||||
isActive ? 'bg-indigo-600/30 text-indigo-100 ring-1 ring-indigo-500/50' : 'text-slate-300 hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab text-slate-600 opacity-60 hover:text-slate-300"
|
||||
title="Arrastrar para reordenar"
|
||||
>
|
||||
⠿
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSubTheme(sub.id)}
|
||||
className="min-w-0 flex-1 truncate text-left"
|
||||
title={sub.title}
|
||||
>
|
||||
{sub.title}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
className="text-slate-500 opacity-0 group-hover:opacity-100 hover:text-slate-200"
|
||||
title="Opciones"
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<ThemeContextMenu
|
||||
mode="sub"
|
||||
initialTitle={sub.title}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
onRename={(title) => useTreeStore.getState().renameSubTheme(sub.id, title)}
|
||||
onDelete={() => useTreeStore.getState().deleteSubTheme(sub.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
src/components/tree/ThemeContextMenu.tsx
Normal file
76
src/components/tree/ThemeContextMenu.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
mode: 'main' | 'sub'
|
||||
initialTitle: string
|
||||
onClose: () => void
|
||||
onRename: (title: string) => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
export function ThemeContextMenu({ mode, initialTitle, onClose, onRename, onDelete }: Props) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [value, setValue] = useState(initialTitle)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) inputRef.current?.focus()
|
||||
}, [editing])
|
||||
|
||||
function commit() {
|
||||
const v = value.trim()
|
||||
if (v) onRename(v)
|
||||
setEditing(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute z-20 ml-6 mt-1 w-48 rounded border border-slate-700 bg-slate-800 p-2 shadow-lg">
|
||||
{editing ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commit()
|
||||
if (e.key === 'Escape') {
|
||||
setEditing(false)
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
className="w-full rounded bg-slate-900 px-2 py-1 text-sm text-slate-100 outline-none ring-1 ring-slate-600"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={commit} className="flex-1 rounded bg-indigo-600 px-2 py-1 text-xs text-white hover:bg-indigo-500">
|
||||
Guardar
|
||||
</button>
|
||||
<button onClick={onClose} className="rounded px-2 py-1 text-xs text-slate-300 hover:bg-slate-700">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="rounded px-2 py-1 text-left text-xs text-slate-200 hover:bg-slate-700"
|
||||
>
|
||||
Renombrar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`¿Eliminar este ${mode === 'main' ? 'tema y todos sus subtemas' : 'subtema'}?`)) {
|
||||
onDelete()
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
className="rounded px-2 py-1 text-left text-xs text-red-300 hover:bg-red-950/50"
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
66
src/components/tree/TreeView.tsx
Normal file
66
src/components/tree/TreeView.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import { useTreeStore } from '../../store/store'
|
||||
import { MainThemeNode } from './MainThemeNode'
|
||||
|
||||
export function TreeView() {
|
||||
const themes = useTreeStore((s) => s.themes)
|
||||
const reorderMainThemes = useTreeStore((s) => s.reorderMainThemes)
|
||||
const reorderSubThemes = useTreeStore((s) => s.reorderSubThemes)
|
||||
const addMainTheme = useTreeStore((s) => s.addMainTheme)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
)
|
||||
|
||||
function onDragEnd(e: DragEndEvent) {
|
||||
const { active, over } = e
|
||||
if (!over || active.id === over.id) return
|
||||
const kind = active.data.current?.kind
|
||||
if (kind === 'main') {
|
||||
const ids = themes.map((t) => t.id)
|
||||
const from = ids.indexOf(String(active.id))
|
||||
const to = ids.indexOf(String(over.id))
|
||||
if (from === -1 || to === -1) return
|
||||
const next = [...ids]
|
||||
const [moved] = next.splice(from, 1)
|
||||
next.splice(to, 0, moved)
|
||||
reorderMainThemes(next)
|
||||
} else if (kind === 'sub') {
|
||||
const parentId = active.data.current?.parentId
|
||||
if (!parentId) return
|
||||
const parent = themes.find((t) => t.id === parentId)
|
||||
if (!parent) return
|
||||
const ids = parent.subThemes.map((s) => s.id)
|
||||
const from = ids.indexOf(String(active.id))
|
||||
const to = ids.indexOf(String(over.id))
|
||||
if (from === -1 || to === -1) return
|
||||
const next = [...ids]
|
||||
const [moved] = next.splice(from, 1)
|
||||
next.splice(to, 0, moved)
|
||||
reorderSubThemes(parentId, next)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<div className="flex flex-col gap-1">
|
||||
{themes.map((t) => (
|
||||
<MainThemeNode key={t.id} theme={t} />
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => addMainTheme()}
|
||||
className="mt-3 w-full rounded border border-dashed border-slate-700 px-2 py-2 text-xs text-slate-400 hover:border-slate-500 hover:text-slate-200"
|
||||
>
|
||||
+ Añadir Tema Principal
|
||||
</button>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue