MNEMO / client /src /pages /AppShell.jsx
SyntaxAdi
feat: add breadcrumb navigation and fix sidebar scrolling
c10cf9e
Raw
History Blame Contribute Delete
28.4 kB
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 (
<div className={`subject-card-wrapper ${isOver ? "drop-target-card" : ""}`} ref={setNodeRef} style={{ position: "relative" }}>
<Link className="subject-card" to={`/app/${card._id}`}>
<div className="subject-card-header">
<span>{card.title}</span>
</div>
<div className="subject-card-body">
<div className="card-stats" style={{ display: "flex", gap: "8px", fontSize: "12px", color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.05em" }}>
<span>{stats.childCount} cards</span>
<span>·</span>
<span>{stats.entryCount} entries</span>
</div>
</div>
</Link>
{mode === "edit" && (
<div className="card-item-actions" style={{ position: "absolute", top: "12px", right: "12px", display: "flex", gap: "8px" }}>
<button
className="icon-button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRename(card);
}}
type="button"
title="Rename"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button
className="icon-button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete(card._id);
}}
type="button"
title="Delete"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</div>
)}
</div>
);
}
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 (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd} sensors={sensors}>
<div className={`app-shell ${sidebarOpen ? "sidebar-open" : "sidebar-closed"}`}>
<header className="top-nav">
<div className="top-nav-left">
<button className="icon-button" onClick={() => setSidebarOpen(!sidebarOpen)} type="button" title={sidebarOpen ? "Close Sidebar" : "Open Sidebar"}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{sidebarOpen ? (
<path d="M18 6L6 18M6 6l12 12" />
) : (
<line x1="3" y1="12" x2="21" y2="12" />
)}
{!sidebarOpen && <line x1="3" y1="6" x2="21" y2="6" />}
{!sidebarOpen && <line x1="3" y1="18" x2="21" y2="18" />}
</svg>
</button>
</div>
<div className="top-nav-center">
<Link className="brand-link" to="/app" style={{ fontWeight: 800, fontSize: "24px", letterSpacing: "0.1em" }}>
MNEMO
</Link>
</div>
<div className="top-nav-right">
<div className="mode-toggle" role="tablist">
<button
className={mode === "read" ? "active" : ""}
onClick={() => setMode("read")}
type="button"
>
Read
</button>
<button
className={mode === "edit" ? "active" : ""}
onClick={() => setMode("edit")}
type="button"
>
Edit
</button>
</div>
</div>
</header>
<div className={`sidebar-backdrop ${sidebarOpen ? "open" : ""}`} onClick={() => setSidebarOpen(false)} />
<aside className={`sidebar ${sidebarOpen ? "open" : ""}`}>
<div className="sidebar-header">Navigation</div>
<div className="sidebar-body">
<SidebarTree roots={rootCards} currentCardId={cardId} onNavigate={() => setSidebarOpen(false)} />
</div>
<div className={`sidebar-footer ${mode === "read" ? "is-hidden" : ""}`} style={{ padding: "16px" }}>
<button className="button-ghost button-block" style={{ fontSize: "11px" }} onClick={() => handleCardCreate(null)} type="button">
+ New Card
</button>
</div>
</aside>
<main className="app-main">
<section className="content-wrap">
<div className="card-header-row" style={{ marginBottom: "var(--space-group)", display: "flex", flexDirection: "column", gap: "24px" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", width: "100%" }}>
<div style={{ flex: 1 }}>
{cardId ? (
<input
className={`display-title-input text-hero ${mode === "read" ? "read-only-display" : ""}`}
disabled={mode === "read"}
style={{ borderBottom: mode === "edit" ? "1px solid var(--border-default)" : "none", marginBottom: "4px" }}
value={titleDraft}
onBlur={handleCardRename}
onChange={(event) => setTitleDraft(event.target.value)}
/>
) : (
<h1 className="display-title text-hero" style={{ marginBottom: "4px" }}>Library</h1>
)}
<div className="breadcrumb-nav" style={{ display: "flex", alignItems: "center", gap: "8px", color: "var(--text-muted)", fontSize: "11px", textTransform: "uppercase", letterSpacing: "0.1em", marginBottom: "12px" }}>
<Link to="/app" style={{ color: "inherit", fontWeight: 600 }}>Root</Link>
{breadcrumb.map((item) => (
<span key={item._id} style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span></span>
<Link
to={`/app/${item._id}`}
style={{
color: item._id === cardId ? "var(--accent)" : "inherit",
fontWeight: item._id === cardId ? 700 : 500
}}
>
{item.title}
</Link>
</span>
))}
</div>
<div style={{ display: "flex", alignItems: "center", gap: "12px", minHeight: "24px" }}>
{cardId && (
<span style={{ color: "var(--text-muted)", fontSize: "12px", textTransform: "uppercase", letterSpacing: "0.05em" }}>
{filteredCards.length} cards · {filteredEntries.length} entries
</span>
)}
</div>
</div>
<div className="settings-menu-container" style={{ marginLeft: "12px" }}>
<button
className="icon-button"
onClick={() => setSettingsOpen(!settingsOpen)}
type="button"
title="Settings"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
{settingsOpen && (
<div className="settings-dropdown">
<div className="settings-section">
<div className="settings-label">Appearance</div>
<div className="settings-row">
<button className={theme === "light" ? "active" : ""} onClick={() => setTheme("light")} type="button">Light</button>
<button className={theme === "dark" ? "active" : ""} onClick={() => setTheme("dark")} type="button">Dark</button>
</div>
</div>
<div className="settings-section">
<div className="settings-label">Text Size</div>
<div className="settings-row">
{["small", "medium", "large", "extra-large"].map(size => (
<button key={size} className={fontSize === size ? "active" : ""} onClick={() => setFontSize(size)} type="button">{size.charAt(0).toUpperCase()}</button>
))}
</div>
</div>
<div className="logout-menu-item" style={{ borderTop: "1px solid var(--border-subtle)", paddingTop: "16px", marginTop: "16px" }}>
<button className="button-ghost button-block" onClick={() => { logout(); navigate("/login"); }} type="button">Logout</button>
</div>
</div>
)}
</div>
</div>
{mode === "edit" && (
<div className="card-header-actions" style={{ display: "flex", gap: "12px" }}>
<button className="button-ghost" style={{ flex: 1 }} onClick={() => handleCardCreate(cardId)} type="button">
+ Add Card
</button>
<button
className="button-primary"
style={{ flex: 1 }}
disabled={!cardId}
onClick={() => {
setEditingEntry(null);
setExpandedIds([]);
setEntryEditorOpen(true);
}}
type="button"
>
+ Add Q&amp;A
</button>
</div>
)}
<div className="search-area-main" style={{ maxWidth: "100%" }}>
<div className="search-box">
<input
placeholder={cardId ? "Search in this card and globally..." : "Global search..."}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{globalSearchResults.length > 0 && (
<div className="search-results-dropdown">
{globalSearchResults.map(result => (
<div
key={result.id}
className="search-result-item"
onClick={() => {
navigate(`/app/${result.cardId}`);
setSearchQuery("");
setExpandedIds([result.id]);
}}
>
<span className="search-result-question">{result.text}</span>
<span className="search-result-path">in {result.cardTitle}</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
{(mode === "edit" || loadingCards || filteredCards.length > 0 || !cardId) && (
<section className="section-block">
<div className="section-title-row" style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: "var(--space-element)" }}>
<h2 className="section-title text-section">
{searchQuery ? "Matching Cards" : "Cards"}
</h2>
{cardId && (
<div className={`card-inline-actions ${mode === "read" ? "is-hidden" : ""}`} style={{ display: "flex", gap: "8px" }}>
<button className="icon-button" onClick={() => handleCardDelete(cardId)} type="button" title="Delete current card">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</div>
)}
</div>
{loadingCards ? (
<div className="card-grid">
{Array.from({ length: 3 }).map((_, index) => (
<div className="skeleton-card" key={index} style={{ height: "160px", borderRadius: "var(--radius-lg)" }} />
))}
</div>
) : filteredCards.length > 0 ? (
<div className="card-grid">
{filteredCards.map((card) => (
<DroppableCard
key={card._id}
card={card}
mode={mode}
stats={getCardStats(cards, entriesByCard, card._id)}
onDelete={handleCardDelete}
onRename={(c) => {
const newTitle = window.prompt("New title", c.title);
if (newTitle && newTitle !== c.title) {
api.patch(`/cards/${c._id}`, { title: newTitle }).then(() => refreshCards());
}
}}
/>
))}
</div>
) : (
<div className="empty-state" style={{ padding: "48px", textAlign: "center", background: "var(--bg-surface)", borderRadius: "var(--radius-lg)", border: "1px dashed var(--border-default)" }}>
{searchQuery ? "No matching cards found." : (
<div style={{ color: "var(--text-muted)" }}>
No cards here yet.
</div>
)}
</div>
)}
</section>
)}
{(mode === "edit" || (cardId && entriesByCard[cardId]) || filteredEntries.length > 0 || cardId) && (
<section className="section-block">
<div className="section-title-row" style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: "var(--space-element)" }}>
<h2 className="section-title text-section">
{searchQuery ? "Matching Entries" : "Entries"}
</h2>
<span style={{ fontSize: "12px", color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.05em" }}>
{filteredEntries.length} Items
</span>
</div>
{cardId ? (
loadingEntries[cardId] ? (
<div className="loading-shimmer" style={{ padding: "48px" }}>Loading knowledge...</div>
) : filteredEntries.length > 0 ? (
<EntryList
entries={filteredEntries}
mode={mode}
onDelete={handleEntryDelete}
onEdit={(entry) => {
setEditingEntry(entry);
setExpandedIds([]);
setEntryEditorOpen(true);
}}
expandedIds={expandedIds}
onToggle={handleToggleEntry}
/>
) : (
<div className="empty-state" style={{ padding: "48px", textAlign: "center", background: "var(--bg-surface)", borderRadius: "var(--radius-lg)", border: "1px dashed var(--border-default)" }}>
{searchQuery ? "No matching entries found." : "No entries yet."}
</div>
)
) : (
<div className="empty-state" style={{ padding: "48px", textAlign: "center", background: "var(--bg-surface)", borderRadius: "var(--radius-lg)", border: "1px dashed var(--border-default)" }}>
{searchQuery ? (filteredEntries.length > 0 ? (
<EntryList
entries={filteredEntries}
mode={mode}
onDelete={handleEntryDelete}
onEdit={(entry) => {
setEditingEntry(entry);
setExpandedIds([]);
setEntryEditorOpen(true);
}}
expandedIds={expandedIds}
onToggle={handleToggleEntry}
/>
) : "No matching entries found.") : "Select a card to view its entries."}
</div>
)}
</section>
)}
{entryEditorOpen && mode === "edit" && (
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.8)", zIndex: 200, display: "flex", alignItems: "center", justifyContent: "center", padding: "20px" }}>
<div className="mobile-modal-full" style={{ background: "var(--bg-base)", width: "100%", maxWidth: "800px", borderRadius: "var(--radius-lg)", border: "1px solid var(--border-default)", boxShadow: "0 24px 48px rgba(0,0,0,0.5)", overflow: "hidden", display: "flex", flexDirection: "column" }}>
<div style={{ padding: "24px", borderBottom: "1px solid var(--border-subtle)", display: "flex", justifyContent: "space-between", alignItems: "center", flexShrink: 0 }}>
<h3 className="text-section" style={{ margin: 0 }}>{editingEntry ? "Edit Entry" : "New Entry"}</h3>
<button className="icon-button" onClick={() => setEntryEditorOpen(false)}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div style={{ padding: "32px", flex: 1, overflow: "auto", WebkitOverflowScrolling: "touch" }}>
<EntryForm
initialValue={editingEntry}
onCancel={() => {
setEntryEditorOpen(false);
setEditingEntry(null);
}}
onSubmit={handleEntrySubmit}
saving={savingEntry}
/>
</div>
</div>
</div>
)}
</section>
</main>
</div>
</DndContext>
);
}