feat: add Merge Notebook feature, stack dissolve/rename, and fix stale view on notebook/tag deletion

This commit is contained in:
chrisf committed 2026-10-22 22:29:50 -06:00
1 parent 507302e6a6
commit 247b63691a
3 files changed
+559 -43

No files matched your search

+73 -1
View File
@@ -6,6 +6,68 @@ export async function POST(req: NextRequest) {
const userId = await getCurrentUserId();
const body = await req.json();
if (body.action === 'merge') {
const sourceNotebookId = body.sourceNotebookId?.trim();
const targetNotebookId = body.targetNotebookId?.trim();
if (!sourceNotebookId || !targetNotebookId) {
return NextResponse.json(
{ error: 'Source and target notebook IDs are required' },
{ status: 400 }
);
}
if (sourceNotebookId === targetNotebookId) {
return NextResponse.json(
{ error: 'Source and target notebooks cannot be the same' },
{ status: 400 }
);
}
const [sourceNb, targetNb] = await Promise.all([
prisma.notebook.findFirst({ where: { id: sourceNotebookId, userId } }),
prisma.notebook.findFirst({ where: { id: targetNotebookId, userId } }),
]);
if (!sourceNb || !targetNb) {
return NextResponse.json(
{ error: 'One or both notebooks not found' },
{ status: 404 }
);
}
// If source notebook was default, make target notebook default
if (sourceNb.isDefault) {
await prisma.notebook.update({
where: { id: targetNotebookId },
data: { isDefault: true },
});
}
// Move all notes (trashed and active) to target notebook
const movedNotes = await prisma.note.updateMany({
where: { notebookId: sourceNotebookId, userId },
data: { notebookId: targetNotebookId },
});
// Update any auto-sort rules that pointed to the source notebook
await prisma.autoSortRule.updateMany({
where: { targetNotebookId: sourceNotebookId, userId },
data: { targetNotebookId },
});
// Delete the source notebook
await prisma.notebook.delete({
where: { id: sourceNotebookId },
});
return NextResponse.json({
success: true,
movedCount: movedNotes.count,
sourceNotebookName: sourceNb.name,
targetNotebookName: targetNb.name,
targetNotebookId,
});
}
if (body.type === 'stack') {
const stackName = body.name?.trim();
if (!stackName) {
@@ -120,6 +182,10 @@ export async function DELETE(req: NextRequest) {
}
if (type === 'stack') {
await prisma.notebook.updateMany({
where: { stackId: id, userId },
data: { stackId: null },
});
await prisma.notebookStack.delete({ where: { id } });
return NextResponse.json({ deleted: true });
}
@@ -134,10 +200,16 @@ export async function DELETE(req: NextRequest) {
// Move notes in this notebook to the default notebook before deleting
await prisma.note.updateMany({
where: { notebookId: id },
where: { notebookId: id, userId },
data: { notebookId: defaultNb.id },
});
// Update any auto-sort rules that pointed to this notebook
await prisma.autoSortRule.updateMany({
where: { targetNotebookId: id, userId },
data: { targetNotebookId: defaultNb.id },
});
await prisma.notebook.delete({ where: { id } });
return NextResponse.json({ deleted: true });
} catch (err: unknown) {
+245 -42
View File
@@ -44,6 +44,7 @@ import {
Wand2,
ArrowRight,
Link2,
GitMerge,
} from 'lucide-react';
import {
SidebarData,
@@ -55,6 +56,7 @@ import RichNoteEditor from './RichNoteEditor';
import SettingsView from './SettingsView';
import ThumbnailEditorModal from './ThumbnailEditorModal';
import TriageModal from './TriageModal';
import MergeNotebookModal from './MergeNotebookModal';
import { PdfAutoThumbnail, InlinePdfViewerCard } from './PdfNoteComponents';
export default function EvercloneWorkspace() {
@@ -241,6 +243,10 @@ export default function EvercloneWorkspace() {
const [creatingTag, setCreatingTag] = useState(false);
const [newTagName, setNewTagName] = useState('');
// Merge Notebooks modal
const [mergeModalOpen, setMergeModalOpen] = useState(false);
const [mergeSourceNotebook, setMergeSourceNotebook] = useState<NotebookItem | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const saveTimerRef = useRef<NodeJS.Timeout | null>(null);
const listParentRef = useRef<HTMLDivElement>(null);
@@ -722,6 +728,7 @@ export default function EvercloneWorkspace() {
if (res.ok) {
const created: NoteDetail = await res.json();
if (activeView === 'trash' || activeView === 'settings') setActiveView('all');
if (searchQuery) setSearchQuery('');
setMobileMenuOpen(false);
openNoteInEditor(created.id);
await fetchSidebar();
@@ -806,6 +813,28 @@ export default function EvercloneWorkspace() {
await fetchNotesList();
};
const handleMergedNotebook = useCallback(
async (
sourceName: string,
targetName: string,
movedCount: number,
targetNotebookId: string
) => {
if (selectedNotebookId === mergeSourceNotebook?.id) {
setSelectedNotebookId(targetNotebookId);
}
await fetchSidebar();
await fetchNotesList();
setShareToast(
`Merged "${sourceName}" into "${targetName}" (${movedCount} ${
movedCount === 1 ? 'note' : 'notes'
} moved)`
);
setTimeout(() => setShareToast(null), 5000);
},
[mergeSourceNotebook?.id, selectedNotebookId, fetchSidebar, fetchNotesList]
);
const handleUploadAttachments = async (files: File[]) => {
if (!activeNote) return [];
const formData = new FormData();
@@ -1447,43 +1476,30 @@ export default function EvercloneWorkspace() {
const isExpanded = expandedStacks[stack.id] ?? true;
return (
<div key={stack.id} className="space-y-0.5">
<div
className={`flex items-center justify-between px-2 py-1.5 rounded cursor-pointer ${
activeView === 'stack' && selectedStackId === stack.id
? 'bg-evernote-sidebarActive text-white font-medium'
: 'hover:bg-evernote-sidebarHover text-gray-300'
}`}
>
<button
type="button"
onClick={() => {
setActiveView('stack');
setSelectedStackId(stack.id);
setMobileMenuOpen(false);
setMobilePane('list');
}}
className="flex items-center gap-2 flex-1 truncate text-left"
>
<Folder className="w-3.5 h-3.5 text-amber-400 shrink-0" />
<span className="truncate font-medium">{stack.name}</span>
</button>
<button
type="button"
onClick={() =>
setExpandedStacks((prev) => ({
...prev,
[stack.id]: !isExpanded,
}))
<SidebarStackRow
stack={stack}
isExpanded={isExpanded}
isActive={activeView === 'stack' && selectedStackId === stack.id}
onToggleExpand={() =>
setExpandedStacks((prev) => ({
...prev,
[stack.id]: !isExpanded,
}))
}
onSelect={() => {
setActiveView('stack');
setSelectedStackId(stack.id);
setMobileMenuOpen(false);
setMobilePane('list');
}}
onRefresh={fetchSidebar}
onDissolved={() => {
if (selectedStackId === stack.id) {
setSelectedStackId('');
setActiveView('all');
}
className="p-0.5 text-gray-500 hover:text-white"
>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</button>
</div>
}}
/>
{isExpanded && (
<div className="pl-4 space-y-0.5">
@@ -1494,6 +1510,17 @@ export default function EvercloneWorkspace() {
active={
activeView === 'notebook' && selectedNotebookId === nb.id
}
canMerge={sidebar.notebooks.length > 1}
onMerge={(item) => {
setMergeSourceNotebook(item);
setMergeModalOpen(true);
}}
onDeleted={() => {
if (selectedNotebookId === nb.id) {
setSelectedNotebookId('');
setActiveView('all');
}
}}
onSelect={() => {
setActiveView('notebook');
setSelectedNotebookId(nb.id);
@@ -1516,6 +1543,17 @@ export default function EvercloneWorkspace() {
key={nb.id}
nb={nb}
active={activeView === 'notebook' && selectedNotebookId === nb.id}
canMerge={sidebar.notebooks.length > 1}
onMerge={(item) => {
setMergeSourceNotebook(item);
setMergeModalOpen(true);
}}
onDeleted={() => {
if (selectedNotebookId === nb.id) {
setSelectedNotebookId('');
setActiveView('all');
}
}}
onSelect={() => {
setActiveView('notebook');
setSelectedNotebookId(nb.id);
@@ -1645,12 +1683,37 @@ export default function EvercloneWorkspace() {
type="button"
onClick={async (e) => {
e.stopPropagation();
if (window.confirm(`Delete tag "${t.name}"?`)) {
await fetch(`/api/tags?id=${t.id}`, { method: 'DELETE' });
fetchSidebar();
const newName = window.prompt('Rename tag:', t.name);
if (newName?.trim() && newName.trim() !== t.name) {
await fetch('/api/tags', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: t.id, name: newName.trim() }),
});
await fetchSidebar();
await fetchNotesList();
}
}}
className="opacity-0 group-hover:opacity-100 text-gray-500 hover:text-red-400"
className="opacity-0 group-hover:opacity-100 p-0.5 text-gray-500 hover:text-white rounded"
title="Rename tag"
>
<MoreHorizontal className="w-3 h-3" />
</button>
<button
type="button"
onClick={async (e) => {
e.stopPropagation();
if (window.confirm(`Delete tag "${t.name}"?`)) {
await fetch(`/api/tags?id=${t.id}`, { method: 'DELETE' });
if (selectedTagId === t.id) {
setSelectedTagId('');
setActiveView('all');
}
await fetchSidebar();
await fetchNotesList();
}
}}
className="opacity-0 group-hover:opacity-100 p-0.5 text-gray-500 hover:text-red-400 rounded"
title="Delete tag"
>
<X className="w-3 h-3" />
@@ -3369,6 +3432,15 @@ export default function EvercloneWorkspace() {
fetchNotesList();
}}
/>
<MergeNotebookModal
open={mergeModalOpen}
onClose={() => setMergeModalOpen(false)}
sourceNotebook={mergeSourceNotebook}
notebooks={sidebar.notebooks}
stacks={sidebar.stacks}
onMerged={handleMergedNotebook}
/>
</>
)}
{/* Floating PWA Install Banner on Mobile */}
@@ -3445,18 +3517,135 @@ export default function EvercloneWorkspace() {
);
}
function SidebarStackRow({
stack,
isExpanded,
isActive,
onToggleExpand,
onSelect,
onRefresh,
onDissolved,
}: {
stack: { id: string; name: string };
isExpanded: boolean;
isActive: boolean;
onToggleExpand: () => void;
onSelect: () => void;
onRefresh: () => void;
onDissolved?: () => void;
}) {
const [menuOpen, setMenuOpen] = useState(false);
return (
<div
className={`group relative flex items-center justify-between px-2 py-1.5 rounded cursor-pointer ${
isActive
? 'bg-evernote-sidebarActive text-white font-medium'
: 'hover:bg-evernote-sidebarHover text-gray-300'
}`}
>
<button
type="button"
onClick={onSelect}
className="flex items-center gap-2 flex-1 truncate text-left"
>
<Folder className="w-3.5 h-3.5 text-amber-400 shrink-0" />
<span className="truncate font-medium">{stack.name}</span>
</button>
<div className="flex items-center gap-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setMenuOpen((o) => !o);
}}
className="opacity-0 group-hover:opacity-100 p-0.5 text-gray-400 hover:text-white rounded"
title="Stack options"
>
<MoreHorizontal className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={onToggleExpand}
className="p-0.5 text-gray-500 hover:text-white"
>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</button>
</div>
{menuOpen && (
<div
onMouseLeave={() => setMenuOpen(false)}
className="absolute right-2 top-7 w-44 bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl py-1 z-30 text-xs text-gray-200"
>
<button
type="button"
onClick={async () => {
setMenuOpen(false);
const newName = window.prompt('Rename stack:', stack.name);
if (newName?.trim() && newName.trim() !== stack.name) {
await fetch('/api/notebooks', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'stack', id: stack.id, name: newName.trim() }),
});
onRefresh();
}
}}
className="w-full text-left px-3 py-1.5 hover:bg-neutral-800"
>
Rename Stack…
</button>
<button
type="button"
onClick={async () => {
setMenuOpen(false);
if (
window.confirm(
`Dissolve stack "${stack.name}"? Notebooks inside will become unstacked.`
)
) {
await fetch(`/api/notebooks?id=${stack.id}&type=stack`, {
method: 'DELETE',
});
if (onDissolved) onDissolved();
onRefresh();
}
}}
className="w-full text-left px-3 py-1.5 hover:bg-neutral-800 text-amber-400"
>
Dissolve Stack
</button>
</div>
)}
</div>
);
}
function NotebookRow({
nb,
active,
onSelect,
onDropNote,
onRefresh,
onMerge,
onDeleted,
canMerge,
}: {
nb: NotebookItem;
active: boolean;
onSelect: () => void;
onDropNote?: (noteId: string) => void;
onRefresh: () => void;
onMerge?: (nb: NotebookItem) => void;
onDeleted?: () => void;
canMerge?: boolean;
}) {
const [menuOpen, setMenuOpen] = useState(false);
const [dragOver, setDragOver] = useState(false);
@@ -3517,7 +3706,7 @@ function NotebookRow({
{menuOpen && (
<div
onMouseLeave={() => setMenuOpen(false)}
className="absolute right-2 top-7 w-44 bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl py-1 z-30 text-xs text-gray-200"
className="absolute right-2 top-7 w-48 bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl py-1 z-30 text-xs text-gray-200"
>
<button
type="button"
@@ -3535,8 +3724,21 @@ function NotebookRow({
}}
className="w-full text-left px-3 py-1.5 hover:bg-neutral-800"
>
Rename Notebook
Rename Notebook…
</button>
{canMerge && onMerge && (
<button
type="button"
onClick={() => {
setMenuOpen(false);
onMerge(nb);
}}
className="w-full text-left px-3 py-1.5 hover:bg-neutral-800 flex items-center gap-2 text-emerald-400 hover:text-emerald-300"
>
<GitMerge className="w-3.5 h-3.5" />
<span>Merge into Notebook…</span>
</button>
)}
<button
type="button"
onClick={async () => {
@@ -3599,6 +3801,7 @@ function NotebookRow({
)
) {
await fetch(`/api/notebooks?id=${nb.id}`, { method: 'DELETE' });
if (onDeleted) onDeleted();
onRefresh();
}
}}
+241
View File
@@ -0,0 +1,241 @@
'use client';
import React, { useState, useEffect } from 'react';
import { GitMerge, X, AlertTriangle, ArrowRight, Loader2 } from 'lucide-react';
import { NotebookItem, NotebookStackItem } from '@/types/everclone';
interface MergeNotebookModalProps {
open: boolean;
onClose: () => void;
sourceNotebook: NotebookItem | null;
notebooks: NotebookItem[];
stacks: NotebookStackItem[];
onMerged: (
sourceName: string,
targetName: string,
movedCount: number,
targetNotebookId: string
) => void;
}
export default function MergeNotebookModal({
open,
onClose,
sourceNotebook,
notebooks,
stacks,
onMerged,
}: MergeNotebookModalProps) {
const [targetNotebookId, setTargetNotebookId] = useState<string>('');
const [merging, setMerging] = useState(false);
const [error, setError] = useState<string | null>(null);
// Available destination notebooks (all except the source notebook itself)
const candidateNotebooks = notebooks.filter((n) => n.id !== sourceNotebook?.id);
useEffect(() => {
if (open && candidateNotebooks.length > 0) {
// Default to the first candidate, or prefer a default notebook if available
const preferred = candidateNotebooks.find((n) => n.isDefault) || candidateNotebooks[0];
setTargetNotebookId(preferred?.id || '');
setError(null);
setMerging(false);
}
}, [open, sourceNotebook?.id]);
if (!open || !sourceNotebook) return null;
const selectedTarget = candidateNotebooks.find((n) => n.id === targetNotebookId);
const getStackName = (stackId: string | null) => {
if (!stackId) return null;
return stacks.find((s) => s.id === stackId)?.name || null;
};
const handleMergeSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!targetNotebookId || !sourceNotebook) return;
setMerging(true);
setError(null);
try {
const res = await fetch('/api/notebooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'merge',
sourceNotebookId: sourceNotebook.id,
targetNotebookId,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to merge notebooks');
}
onMerged(
data.sourceNotebookName || sourceNotebook.name,
data.targetNotebookName || selectedTarget?.name || 'Destination Notebook',
data.movedCount ?? sourceNotebook.noteCount ?? 0,
data.targetNotebookId || targetNotebookId
);
onClose();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Error merging notebooks');
} finally {
setMerging(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-150">
<div className="relative w-full max-w-lg bg-neutral-900 border border-neutral-700 rounded-2xl shadow-2xl overflow-hidden text-neutral-100">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-800 bg-neutral-900/80">
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-xl bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
<GitMerge className="w-5 h-5" />
</div>
<div>
<h2 className="text-sm font-semibold text-white">Merge Notebook</h2>
<p className="text-xs text-neutral-400">
Move all notes into another notebook and remove the source
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
disabled={merging}
className="p-1 text-neutral-400 hover:text-white rounded-lg hover:bg-neutral-800 transition disabled:opacity-50"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Form Body */}
<form onSubmit={handleMergeSubmit} className="p-6 space-y-4">
{error && (
<div className="p-3 bg-red-950/60 border border-red-800/80 rounded-xl text-xs text-red-200 flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
<span>{error}</span>
</div>
)}
{/* Visual Source -> Target flow */}
<div className="p-4 bg-neutral-800/60 border border-neutral-700/60 rounded-xl flex items-center justify-between gap-3 text-xs">
<div className="flex-1 truncate">
<span className="block text-[11px] text-neutral-400 uppercase font-semibold">Source</span>
<span className="font-medium text-white truncate block">{sourceNotebook.name}</span>
<span className="text-[11px] text-neutral-400">
{sourceNotebook.noteCount ?? 0} {sourceNotebook.noteCount === 1 ? 'note' : 'notes'}
</span>
</div>
<div className="flex items-center justify-center p-2 rounded-full bg-neutral-700/50 text-neutral-300">
<ArrowRight className="w-4 h-4" />
</div>
<div className="flex-1 truncate text-right">
<span className="block text-[11px] text-neutral-400 uppercase font-semibold">Destination</span>
<span className="font-medium text-white truncate block">
{selectedTarget?.name || 'Select destination'}
</span>
<span className="text-[11px] text-neutral-400">
{selectedTarget ? `${selectedTarget.noteCount ?? 0} notes currently` : '—'}
</span>
</div>
</div>
{/* Destination Notebook Select */}
<div className="space-y-1.5">
<label htmlFor="merge-destination-select" className="block text-xs font-medium text-neutral-300">
Select Destination Notebook
</label>
<select
id="merge-destination-select"
value={targetNotebookId}
onChange={(e) => setTargetNotebookId(e.target.value)}
disabled={merging || candidateNotebooks.length === 0}
className="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-xl text-xs text-white focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
>
{candidateNotebooks.map((nb) => {
const stack = getStackName(nb.stackId);
const label = stack
? `${stack} / ${nb.name}${nb.isDefault ? ' (Default)' : ''} — ${nb.noteCount} notes`
: `${nb.name}${nb.isDefault ? ' (Default)' : ''} — ${nb.noteCount} notes`;
return (
<option key={nb.id} value={nb.id}>
{label}
</option>
);
})}
</select>
</div>
{/* Notice Explanations */}
<div className="p-3 bg-neutral-800/40 border border-neutral-700/40 rounded-xl space-y-1.5 text-[11px] text-neutral-400">
<p className="flex items-start gap-1.5">
<span className="text-emerald-400 font-bold">•</span>
<span>
All notes and tags will be reassigned to{' '}
<strong className="text-neutral-200">{selectedTarget?.name || 'the destination notebook'}</strong>.
</span>
</p>
{sourceNotebook.isDefault && (
<p className="flex items-start gap-1.5 text-amber-300/90">
<span className="text-amber-400 font-bold">•</span>
<span>
<strong>{sourceNotebook.name}</strong> is currently your default notebook. The destination notebook will become your new default notebook.
</span>
</p>
)}
<p className="flex items-start gap-1.5">
<span className="text-emerald-400 font-bold">•</span>
<span>
Any auto-sorting rules pointing to <strong>{sourceNotebook.name}</strong> will be updated automatically.
</span>
</p>
<p className="flex items-start gap-1.5 text-red-300/80">
<span className="text-red-400 font-bold">•</span>
<span>
<strong>{sourceNotebook.name}</strong> will be deleted once all notes are moved.
</span>
</p>
</div>
{/* Modal Actions */}
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-neutral-800">
<button
type="button"
onClick={onClose}
disabled={merging}
className="px-4 py-2 rounded-xl text-xs font-medium text-neutral-400 hover:text-white hover:bg-neutral-800 transition disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={merging || !targetNotebookId}
className="flex items-center gap-1.5 px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-xl text-xs font-semibold shadow-md transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{merging ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>Merging…</span>
</>
) : (
<>
<GitMerge className="w-3.5 h-3.5" />
<span>Merge Notebooks</span>
</>
)}
</button>
</div>
</form>
</div>
</div>
);
}