| 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&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> |
| ); |
| } |
|
|