import { useEffect, useMemo, useState } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; import { DndContext, PointerSensor, TouchSensor, closestCenter, useSensor, useSensors, useDroppable } from "@dnd-kit/core"; import { arrayMove } from "@dnd-kit/sortable"; import { api } from "../lib/api"; import { useAuth } from "../context/AuthContext"; import { useData } from "../context/DataContext"; import { usePreferences } from "../context/PreferencesContext"; import { getAncestorChain } from "../lib/tree"; import { SidebarTree } from "../components/SidebarTree"; import { EntryList } from "../components/EntryList"; import { EntryForm } from "../components/EntryForm"; function getCardStats(cards, entriesByCard, cardId) { const children = cards.filter((card) => String(card.parentId || "") === String(cardId || "")); const directEntries = (entriesByCard[cardId] || []).length; return { childCount: children.length, entryCount: directEntries }; } function DroppableCard({ card, stats, mode, onRename, onDelete }) { const { isOver, setNodeRef } = useDroppable({ id: `card-grid-${card._id}`, data: { type: "card", cardId: card._id } }); return (
{card.title}
{stats.childCount} cards · {stats.entryCount} entries
{mode === "edit" && (
)}
); } export function AppShell() { const { cardId } = useParams(); const navigate = useNavigate(); const { logout } = useAuth(); const { cards, tree, entriesByCard, refreshCards, loadEntries, setEntriesByCard, loadingCards, loadingEntries } = useData(); const { theme, setTheme, mode, setMode, fontSize, setFontSize, sidebarOpen, setSidebarOpen } = usePreferences(); const [entryEditorOpen, setEntryEditorOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [editingEntry, setEditingEntry] = useState(null); const [savingEntry, setSavingEntry] = useState(false); const [titleDraft, setTitleDraft] = useState(""); const [searchQuery, setSearchQuery] = useState(""); const [expandedIds, setExpandedIds] = useState([]); const sensors = useSensors( useSensor(PointerSensor), useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 5, }, }) ); function handleToggleEntry(id) { setExpandedIds((current) => current.includes(id) ? [] : [id] ); } useEffect(() => { function handleClickOutside(event) { if (settingsOpen && !event.target.closest(".settings-menu-container")) { setSettingsOpen(false); } if (searchQuery && !event.target.closest(".search-box")) { setSearchQuery(""); } } document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [settingsOpen, searchQuery]); useEffect(() => { refreshCards(); }, []); useEffect(() => { if (cardId) { loadEntries(cardId); } }, [cardId]); const currentCard = useMemo(() => tree.map.get(cardId), [cardId, tree.map]); const rootCards = tree.roots; const filteredCards = useMemo(() => { const list = cards.filter((card) => String(card.parentId || "") === String(cardId || "")); if (!searchQuery.trim()) return list; const q = searchQuery.toLowerCase(); return list.filter(c => c.title.toLowerCase().includes(q)); }, [cardId, cards, searchQuery]); const entries = cardId ? entriesByCard[cardId] || [] : []; const filteredEntries = useMemo(() => { if (!searchQuery.trim()) return entries; const q = searchQuery.toLowerCase(); return entries.filter(e => e.question.toLowerCase().includes(q) || e.answer.toLowerCase().includes(q) ); }, [entries, searchQuery]); const breadcrumb = useMemo(() => getAncestorChain(cardId, tree.map), [cardId, tree.map]); const globalSearchResults = useMemo(() => { if (!searchQuery.trim() || searchQuery.length < 2) return []; const q = searchQuery.toLowerCase(); const results = []; Object.entries(entriesByCard).forEach(([cid, cardEntries]) => { cardEntries.forEach(entry => { if (entry.question.toLowerCase().includes(q) || entry.answer.toLowerCase().includes(q)) { results.push({ type: "entry", id: entry._id, cardId: cid, text: entry.question, cardTitle: tree.map.get(cid)?.title || "Root" }); } }); }); return results.slice(0, 10); }, [entriesByCard, searchQuery, tree.map]); useEffect(() => { setTitleDraft(currentCard?.title || ""); }, [currentCard]); async function handleCardCreate(parentId) { const title = window.prompt("Card title"); if (!title) { return; } const { data } = await api.post("/cards", { title, parentId: parentId || null }); await refreshCards(); if (data.card?._id) { navigate(`/app/${data.card._id}`); } } async function handleCardRename() { if (!currentCard || !titleDraft.trim()) { return; } await api.patch(`/cards/${currentCard._id}`, { title: titleDraft.trim() }); await refreshCards(); } async function handleCardDelete(id) { if (!window.confirm("Delete card and all descendants?")) { return; } await api.delete(`/cards/${id}`); await refreshCards(); navigate("/app"); } async function handleEntrySubmit(form) { if (!cardId) { return; } setSavingEntry(true); try { if (editingEntry) { await api.patch(`/entries/${editingEntry._id}`, form); } else { await api.post("/entries", { ...form, cardId }); } await loadEntries(cardId); setEntryEditorOpen(false); setEditingEntry(null); } finally { setSavingEntry(false); } } async function handleEntryDelete(entry) { if (!window.confirm("Delete entry?")) { return; } await api.delete(`/entries/${entry._id}`); await loadEntries(entry.cardId); } async function handleEntryReorder(reordered) { setEntriesByCard((current) => ({ ...current, [cardId]: reordered })); await api.patch("/entries/reorder", { entries: reordered.map((entry, index) => ({ id: entry._id, order: index })) }); await loadEntries(cardId); } async function handleMoveEntry(entryId, newCardId) { if (!newCardId || (cardId && newCardId === cardId)) return; let entryToMove = null; let sourceCardId = cardId; if (sourceCardId) { entryToMove = (entriesByCard[sourceCardId] || []).find(e => e._id === entryId); } else { for (const [cid, cardEntries] of Object.entries(entriesByCard)) { const found = cardEntries.find(e => e._id === entryId); if (found) { entryToMove = found; sourceCardId = cid; break; } } } if (!entryToMove) return; setEntriesByCard((current) => { const next = { ...current }; if (sourceCardId && next[sourceCardId]) { next[sourceCardId] = next[sourceCardId].filter(e => e._id !== entryId); } const targetEntries = next[newCardId] || []; const newEntry = { ...entryToMove, cardId: newCardId, order: targetEntries.length }; next[newCardId] = [...targetEntries, newEntry]; return next; }); try { await api.patch(`/entries/${entryId}`, { cardId: newCardId, order: (entriesByCard[newCardId] || []).length }); if (sourceCardId) loadEntries(sourceCardId, true); loadEntries(newCardId, true); } catch (error) { console.error("Failed to move entry:", error); refreshCards(); } } async function handleDragEnd(event) { const { active, over } = event; if (!over) return; if (over.data.current?.type === "card") { const entryId = active.id; const newCardId = over.data.current.cardId; await handleMoveEntry(entryId, newCardId); return; } if (active.id !== over.id) { const oldIndex = entries.findIndex((e) => e._id === active.id); const newIndex = entries.findIndex((e) => e._id === over.id); if (oldIndex !== -1 && newIndex !== -1) { const reordered = arrayMove(entries, oldIndex, newIndex).map((entry, index) => ({ ...entry, order: index })); handleEntryReorder(reordered); } } } return (
MNEMO
setSidebarOpen(false)} />
{cardId ? ( setTitleDraft(event.target.value)} /> ) : (

Library

)}
Root {breadcrumb.map((item) => ( {item.title} ))}
{cardId && ( {filteredCards.length} cards · {filteredEntries.length} entries )}
{settingsOpen && (
Appearance
Text Size
{["small", "medium", "large", "extra-large"].map(size => ( ))}
)}
{mode === "edit" && (
)}
setSearchQuery(e.target.value)} /> {globalSearchResults.length > 0 && (
{globalSearchResults.map(result => (
{ navigate(`/app/${result.cardId}`); setSearchQuery(""); setExpandedIds([result.id]); }} > {result.text} in {result.cardTitle}
))}
)}
{(mode === "edit" || loadingCards || filteredCards.length > 0 || !cardId) && (

{searchQuery ? "Matching Cards" : "Cards"}

{cardId && (
)}
{loadingCards ? (
{Array.from({ length: 3 }).map((_, index) => (
))}
) : filteredCards.length > 0 ? (
{filteredCards.map((card) => ( { const newTitle = window.prompt("New title", c.title); if (newTitle && newTitle !== c.title) { api.patch(`/cards/${c._id}`, { title: newTitle }).then(() => refreshCards()); } }} /> ))}
) : (
{searchQuery ? "No matching cards found." : (
No cards here yet.
)}
)}
)} {(mode === "edit" || (cardId && entriesByCard[cardId]) || filteredEntries.length > 0 || cardId) && (

{searchQuery ? "Matching Entries" : "Entries"}

{filteredEntries.length} Items
{cardId ? ( loadingEntries[cardId] ? (
Loading knowledge...
) : filteredEntries.length > 0 ? ( { setEditingEntry(entry); setExpandedIds([]); setEntryEditorOpen(true); }} expandedIds={expandedIds} onToggle={handleToggleEntry} /> ) : (
{searchQuery ? "No matching entries found." : "No entries yet."}
) ) : (
{searchQuery ? (filteredEntries.length > 0 ? ( { setEditingEntry(entry); setExpandedIds([]); setEntryEditorOpen(true); }} expandedIds={expandedIds} onToggle={handleToggleEntry} /> ) : "No matching entries found.") : "Select a card to view its entries."}
)}
)} {entryEditorOpen && mode === "edit" && (

{editingEntry ? "Edit Entry" : "New Entry"}

{ setEntryEditorOpen(false); setEditingEntry(null); }} onSubmit={handleEntrySubmit} saving={savingEntry} />
)}
); }