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 (
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}
/>
)}
);
}