feat: add Merge Notebook feature, stack dissolve/rename, and fix stale view on notebook/tag deletion
This commit is contained in:
1 parent
507302e6a6
commit
247b63691a
3 files changed
+559
-43
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user