feat(interface): frontend updates and Nexus branding
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Regular → Executable
+278
-24
@@ -13,6 +13,28 @@ function groupBySection(items) {
|
||||
return map;
|
||||
}
|
||||
|
||||
function indentLevelOf(text) {
|
||||
const m = text.match(/^( *)/);
|
||||
return Math.floor((m ? m[0].length : 0) / 2);
|
||||
}
|
||||
|
||||
// Collapse a section's items into top-level "groups": each group is a parent
|
||||
// (indent 0) plus its consecutive indented children.
|
||||
function computeGroups(sectionItems) {
|
||||
const groups = [];
|
||||
const groupIndexById = {};
|
||||
for (const item of sectionItems) {
|
||||
const lvl = indentLevelOf(item.text);
|
||||
if (lvl === 0 || groups.length === 0) {
|
||||
groups.push([item]);
|
||||
} else {
|
||||
groups[groups.length - 1].push(item);
|
||||
}
|
||||
groupIndexById[item.id] = groups.length - 1;
|
||||
}
|
||||
return { groups, groupIndexById };
|
||||
}
|
||||
|
||||
export function Memory() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [editing, setEditing] = useState(null); // { id, section, text } or null
|
||||
@@ -20,6 +42,8 @@ export function Memory() {
|
||||
const [newSection, setNewSection] = useState("");
|
||||
const [showNewSection, setShowNewSection] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [draggingId, setDraggingId] = useState(null);
|
||||
const [dragOver, setDragOver] = useState(null); // { id, position: "before"|"after" }
|
||||
|
||||
const loadItems = useCallback(async () => {
|
||||
try {
|
||||
@@ -36,22 +60,30 @@ export function Memory() {
|
||||
const flash = (msg) => { setMessage(msg); setTimeout(() => setMessage(""), 3000); };
|
||||
|
||||
const saveNew = async (section, text) => {
|
||||
if (!text.trim()) return;
|
||||
const res = await fetch(`${API_BASE}/memory`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ section, text: text.trim() }),
|
||||
});
|
||||
if (res.ok) { loadItems(); setAdding(null); flash("Saved."); }
|
||||
// Split on newlines so a pasted list becomes one memory per line (leading
|
||||
// spaces kept for nesting). Direct write — no LLM curator, saves every line.
|
||||
const lines = text.split("\n").map(l => l.replace(/\s+$/, "")).filter(l => l.trim());
|
||||
if (!lines.length) return;
|
||||
let ok = 0;
|
||||
for (const line of lines) {
|
||||
const res = await fetch(`${API_BASE}/memory`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ section, text: line }),
|
||||
});
|
||||
if (res.ok) ok++;
|
||||
}
|
||||
if (ok) { loadItems(); setAdding(null); flash(ok === 1 ? "Saved." : `Saved ${ok} items.`); }
|
||||
else flash("Save failed.");
|
||||
};
|
||||
|
||||
const saveEdit = async (id, section, text) => {
|
||||
if (!text.trim()) return;
|
||||
const cleaned = text.replace(/\s+$/, "");
|
||||
if (!cleaned.trim()) return;
|
||||
const res = await fetch(`${API_BASE}/memory/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ section, text: text.trim() }),
|
||||
body: JSON.stringify({ section, text: cleaned }),
|
||||
});
|
||||
if (res.ok) { loadItems(); setEditing(null); flash("Updated."); }
|
||||
else flash("Update failed.");
|
||||
@@ -64,6 +96,154 @@ export function Memory() {
|
||||
else flash("Delete failed.");
|
||||
};
|
||||
|
||||
const reorderSection = async (section, ids) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/memory/reorder`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ section, ids }),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!res.ok || result.ok === false) {
|
||||
throw new Error("Memory reorder failed");
|
||||
}
|
||||
loadItems();
|
||||
} catch (err) {
|
||||
setMessage(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const moveItem = (id, direction) => {
|
||||
const item = items.find(i => i.id === id);
|
||||
if (!item) return;
|
||||
const section = item.section || "General";
|
||||
const sectionItems = items.filter(i => (i.section || "General") === section);
|
||||
const { groups, groupIndexById } = computeGroups(sectionItems);
|
||||
const lvl = indentLevelOf(item.text);
|
||||
|
||||
if (lvl === 0) {
|
||||
// Swap whole group with previous/next group
|
||||
const gIdx = groupIndexById[id];
|
||||
const tIdx = direction === "up" ? gIdx - 1 : gIdx + 1;
|
||||
if (tIdx < 0 || tIdx >= groups.length) return;
|
||||
const newGroups = [...groups];
|
||||
[newGroups[gIdx], newGroups[tIdx]] = [newGroups[tIdx], newGroups[gIdx]];
|
||||
reorderSection(section, newGroups.flat().map(i => i.id));
|
||||
} else {
|
||||
// Swap child with previous/next sibling within same group; never past the parent
|
||||
const gIdx = groupIndexById[id];
|
||||
const group = groups[gIdx];
|
||||
const localIdx = group.findIndex(i => i.id === id);
|
||||
const tLocal = direction === "up" ? localIdx - 1 : localIdx + 1;
|
||||
if (tLocal <= 0 || tLocal >= group.length) return; // tLocal === 0 is the parent
|
||||
const newGroup = [...group];
|
||||
[newGroup[localIdx], newGroup[tLocal]] = [newGroup[tLocal], newGroup[localIdx]];
|
||||
const newGroups = [...groups];
|
||||
newGroups[gIdx] = newGroup;
|
||||
reorderSection(section, newGroups.flat().map(i => i.id));
|
||||
}
|
||||
};
|
||||
|
||||
const onItemDragStart = (e, item) => {
|
||||
setDraggingId(item.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
try { e.dataTransfer.setData("text/plain", item.id); } catch { /* some browsers restrict */ }
|
||||
};
|
||||
|
||||
const onItemDragOver = (e, targetItem, section) => {
|
||||
if (!draggingId || draggingId === targetItem.id) return;
|
||||
const dragged = items.find(i => i.id === draggingId);
|
||||
if (!dragged || (dragged.section || "General") !== section) return;
|
||||
|
||||
const sectionItems = items.filter(i => (i.section || "General") === section);
|
||||
const { groups, groupIndexById } = computeGroups(sectionItems);
|
||||
const draggedLevel = indentLevelOf(dragged.text);
|
||||
const targetGroupIdx = groupIndexById[targetItem.id];
|
||||
const draggedGroupIdx = groupIndexById[dragged.id];
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const inUpperHalf = e.clientY < rect.top + rect.height / 2;
|
||||
|
||||
if (draggedLevel === 0) {
|
||||
// Top-level drag: snap to group boundaries; ignore hover over own group
|
||||
if (targetGroupIdx === draggedGroupIdx) {
|
||||
if (dragOver) setDragOver(null);
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
const targetGroup = groups[targetGroupIdx];
|
||||
const isTargetParent = targetItem.id === targetGroup[0].id;
|
||||
const position = (isTargetParent && inUpperHalf) ? "before" : "after";
|
||||
if (dragOver?.kind !== "group" || dragOver?.section !== section || dragOver?.groupIdx !== targetGroupIdx || dragOver?.position !== position) {
|
||||
setDragOver({ kind: "group", section, groupIdx: targetGroupIdx, position });
|
||||
}
|
||||
} else {
|
||||
// Child drag: confined to its own parent group
|
||||
if (targetGroupIdx !== draggedGroupIdx) {
|
||||
if (dragOver) setDragOver(null);
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
const targetGroup = groups[targetGroupIdx];
|
||||
const isTargetParent = targetItem.id === targetGroup[0].id;
|
||||
let position = inUpperHalf ? "before" : "after";
|
||||
if (isTargetParent) position = "after"; // child can't go before its parent
|
||||
if (dragOver?.kind !== "item" || dragOver?.section !== section || dragOver?.id !== targetItem.id || dragOver?.position !== position) {
|
||||
setDragOver({ kind: "item", section, id: targetItem.id, position });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onItemDrop = (e, targetItem, section) => {
|
||||
e.preventDefault();
|
||||
const dragged = items.find(i => i.id === draggingId);
|
||||
if (!dragged || dragged.id === targetItem.id || (dragged.section || "General") !== section || !dragOver) {
|
||||
setDraggingId(null);
|
||||
setDragOver(null);
|
||||
return;
|
||||
}
|
||||
const sectionItems = items.filter(i => (i.section || "General") === section);
|
||||
const { groups, groupIndexById } = computeGroups(sectionItems);
|
||||
const draggedLevel = indentLevelOf(dragged.text);
|
||||
|
||||
let block, anchorId, after;
|
||||
if (draggedLevel === 0 && dragOver.kind === "group") {
|
||||
block = groups[groupIndexById[dragged.id]];
|
||||
const targetGroup = groups[dragOver.groupIdx];
|
||||
anchorId = dragOver.position === "before" ? targetGroup[0].id : targetGroup[targetGroup.length - 1].id;
|
||||
after = dragOver.position === "after";
|
||||
} else if (draggedLevel > 0 && dragOver.kind === "item") {
|
||||
block = [dragged];
|
||||
anchorId = dragOver.id;
|
||||
after = dragOver.position === "after";
|
||||
} else {
|
||||
setDraggingId(null);
|
||||
setDragOver(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const blockIds = new Set(block.map(b => b.id));
|
||||
const remainder = sectionItems.filter(i => !blockIds.has(i.id));
|
||||
let insertIdx = remainder.findIndex(i => i.id === anchorId);
|
||||
if (insertIdx < 0) {
|
||||
setDraggingId(null);
|
||||
setDragOver(null);
|
||||
return;
|
||||
}
|
||||
if (after) insertIdx += 1;
|
||||
const newOrder = [...remainder.slice(0, insertIdx), ...block, ...remainder.slice(insertIdx)];
|
||||
|
||||
setDraggingId(null);
|
||||
setDragOver(null);
|
||||
reorderSection(section, newOrder.map(i => i.id));
|
||||
};
|
||||
|
||||
const onItemDragEnd = () => {
|
||||
setDraggingId(null);
|
||||
setDragOver(null);
|
||||
};
|
||||
|
||||
const addSection = () => {
|
||||
const sec = newSection.trim();
|
||||
if (!sec) return;
|
||||
@@ -98,7 +278,7 @@ export function Memory() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ flexGrow: 1, maxWidth: "860px" }}>
|
||||
<div style={{ flexGrow: 1 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1.25rem" }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: "1.1rem" }}>Memory</h2>
|
||||
@@ -148,9 +328,49 @@ export function Memory() {
|
||||
</div>
|
||||
|
||||
{/* Bullet items */}
|
||||
{(() => {
|
||||
const sectionItems = grouped[section] || [];
|
||||
const { groups, groupIndexById } = computeGroups(sectionItems);
|
||||
return (
|
||||
<div style={{ paddingLeft: "1rem", borderLeft: "2px solid #222" }}>
|
||||
{(grouped[section] || []).map(item => (
|
||||
<div key={item.id} style={{ marginBottom: "0.4rem" }}>
|
||||
{sectionItems.map((item) => {
|
||||
const indentMatch = item.text.match(/^( *)(.*)$/s);
|
||||
const indentLevel = indentMatch ? Math.floor(indentMatch[1].length / 2) : 0;
|
||||
const displayText = indentMatch ? indentMatch[2] : item.text;
|
||||
const gIdx = groupIndexById[item.id];
|
||||
const group = groups[gIdx];
|
||||
const localIdx = group.findIndex(i => i.id === item.id);
|
||||
const isParentRow = localIdx === 0;
|
||||
const isLastInGroup = localIdx === group.length - 1;
|
||||
const arrowUpDisabled = indentLevel === 0 ? gIdx === 0 : localIdx <= 1;
|
||||
const arrowDownDisabled = indentLevel === 0 ? gIdx === groups.length - 1 : isLastInGroup;
|
||||
const isDragging = draggingId === item.id;
|
||||
let showLineBefore = false, showLineAfter = false;
|
||||
if (dragOver && dragOver.section === section) {
|
||||
if (dragOver.kind === "group" && dragOver.groupIdx === gIdx) {
|
||||
if (dragOver.position === "before" && isParentRow) showLineBefore = true;
|
||||
if (dragOver.position === "after" && isLastInGroup) showLineAfter = true;
|
||||
} else if (dragOver.kind === "item" && dragOver.id === item.id) {
|
||||
if (dragOver.position === "before") showLineBefore = true;
|
||||
else showLineAfter = true;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
draggable={!editing}
|
||||
onDragStart={(e) => onItemDragStart(e, item)}
|
||||
onDragOver={(e) => onItemDragOver(e, item, section)}
|
||||
onDrop={(e) => onItemDrop(e, item, section)}
|
||||
onDragEnd={onItemDragEnd}
|
||||
style={{
|
||||
marginBottom: "0.4rem",
|
||||
paddingLeft: `${indentLevel * 1.5}rem`,
|
||||
borderTop: showLineBefore ? "2px solid #4db8ff" : "2px solid transparent",
|
||||
borderBottom: showLineAfter ? "2px solid #4db8ff" : "2px solid transparent",
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
{editing?.id === item.id ? (
|
||||
<EditRow
|
||||
initial={editing.text}
|
||||
@@ -160,17 +380,29 @@ export function Memory() {
|
||||
btnStyle={btnStyle}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: "0.5rem", group: true }}>
|
||||
<div
|
||||
onDoubleClick={() => setEditing({ id: item.id, section, text: item.text })}
|
||||
title="Drag to reorder · double-click to edit"
|
||||
style={{ display: "flex", alignItems: "flex-start", gap: "0.5rem", cursor: "grab" }}
|
||||
>
|
||||
<span style={{ color: "#aaa", marginTop: "0.1rem", flexShrink: 0 }}>-</span>
|
||||
<span style={{ color: "#ddd", fontSize: "0.9rem", flexGrow: 1 }}>{item.text}</span>
|
||||
<span style={{ color: "#ddd", fontSize: "0.9rem", flexGrow: 1 }}>{displayText}</span>
|
||||
<div style={{ display: "flex", gap: "0.3rem", flexShrink: 0, opacity: 0.4 }}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = 1}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = 0.4}
|
||||
>
|
||||
<button
|
||||
onClick={() => setEditing({ id: item.id, section, text: item.text })}
|
||||
style={{ ...btnStyle("#333"), fontSize: "0.72rem", padding: "0.2rem 0.5rem" }}
|
||||
>edit</button>
|
||||
onClick={() => moveItem(item.id, "up")}
|
||||
disabled={arrowUpDisabled}
|
||||
title="Move up"
|
||||
style={{ ...btnStyle("#333"), fontSize: "0.72rem", padding: "0.2rem 0.4rem", opacity: arrowUpDisabled ? 0.3 : 1, cursor: arrowUpDisabled ? "default" : "pointer" }}
|
||||
>▲</button>
|
||||
<button
|
||||
onClick={() => moveItem(item.id, "down")}
|
||||
disabled={arrowDownDisabled}
|
||||
title="Move down"
|
||||
style={{ ...btnStyle("#333"), fontSize: "0.72rem", padding: "0.2rem 0.4rem", opacity: arrowDownDisabled ? 0.3 : 1, cursor: arrowDownDisabled ? "default" : "pointer" }}
|
||||
>▼</button>
|
||||
<button
|
||||
onClick={() => deleteItem(item.id)}
|
||||
style={{ ...btnStyle("#5a1a1a"), fontSize: "0.72rem", padding: "0.2rem 0.5rem" }}
|
||||
@@ -179,7 +411,8 @@ export function Memory() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Inline add form */}
|
||||
{adding?.section === section && (
|
||||
@@ -191,6 +424,8 @@ export function Memory() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -203,19 +438,34 @@ export function Memory() {
|
||||
);
|
||||
}
|
||||
|
||||
function handleIndentKey(e, setText) {
|
||||
if (e.key !== "Tab") return false;
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
setText(t => t.startsWith(" ") ? t.slice(2) : t);
|
||||
} else {
|
||||
setText(t => " " + t);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function AddRow({ onSave, onCancel, inputStyle, btnStyle }) {
|
||||
const [text, setText] = useState("");
|
||||
return (
|
||||
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.4rem" }}>
|
||||
<span style={{ color: "#555", flexShrink: 0 }}>-</span>
|
||||
<input
|
||||
<textarea
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="New item..."
|
||||
rows={1}
|
||||
placeholder="New item… (paste multiple lines to add several at once)"
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === "Enter") onSave(text); if (e.key === "Escape") onCancel(); }}
|
||||
style={{ ...inputStyle }}
|
||||
onKeyDown={e => {
|
||||
if (handleIndentKey(e, setText)) return;
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onSave(text); }
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
style={{ ...inputStyle, resize: "vertical", fontFamily: "inherit", minHeight: "2.1rem" }}
|
||||
/>
|
||||
<button onClick={() => onSave(text)} style={btnStyle()}>Save</button>
|
||||
<button onClick={onCancel} style={btnStyle("#444")}>✕</button>
|
||||
@@ -233,7 +483,11 @@ function EditRow({ initial, onSave, onCancel, inputStyle, btnStyle }) {
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === "Enter") onSave(text); if (e.key === "Escape") onCancel(); }}
|
||||
onKeyDown={e => {
|
||||
if (handleIndentKey(e, setText)) return;
|
||||
if (e.key === "Enter") onSave(text);
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
style={{ ...inputStyle }}
|
||||
/>
|
||||
<button onClick={() => onSave(text)} style={btnStyle()}>Save</button>
|
||||
|
||||
Reference in New Issue
Block a user