Fix notebook drag highlight and add drag-to-tag and drag-to-trash support

This commit is contained in:
chrisf committed 2026-10-26 10:29:18 -06:00
1 parent 0f24af880b
commit 2cc202ce03
2 files changed
+304 -87

No files matched your search

+13
View File
@@ -188,6 +188,19 @@ export async function PATCH(
}
}
if (body.addTagId && typeof body.addTagId === 'string') {
await prisma.noteTag.createMany({
data: [{ noteId: id, tagId: body.addTagId }],
skipDuplicates: true,
});
}
if (body.removeTagId && typeof body.removeTagId === 'string') {
await prisma.noteTag.deleteMany({
where: { noteId: id, tagId: body.removeTagId },
});
}
const updated = await prisma.note.update({
where: { id },
data,
+291 -87
View File
@@ -1233,6 +1233,47 @@ export default function EvercloneWorkspace() {
}
};
const handleTagNoteById = async (noteId: string, tagId: string, tagName: string) => {
if (!noteId || !tagId) return;
try {
const res = await fetch(`/api/notes/${noteId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ addTagId: tagId }),
});
if (res.ok) {
const updated: NoteDetail = await res.json();
if (activeNote?.id === noteId) {
setActiveNote(updated);
}
await fetchSidebar();
}
} catch (e) {
console.error('Failed to add tag to note via drag & drop:', e);
}
};
const handleTrashNoteById = async (noteId: string) => {
if (!noteId) return;
try {
if (activeView !== 'trash') {
setNotes((prev) => prev.filter((n) => n.id !== noteId));
if (selectedNoteId === noteId) {
setSelectedNoteId('');
setActiveNote(null);
}
}
await fetch(`/api/notes/${noteId}?permanent=false`, {
method: 'DELETE',
});
await fetchSidebar();
await fetchNotesList();
} catch (e) {
console.error('Failed to move note to trash via drag & drop:', e);
await fetchNotesList();
}
};
const handleSwitchUser = async (userId: string) => {
setUserMenuOpen(false);
const res = await fetch('/api/users', {
@@ -1884,73 +1925,26 @@ export default function EvercloneWorkspace() {
t.name.toLowerCase().includes(sidebarTagSearch.trim().toLowerCase())
)
.map((t) => (
<div
<SidebarTagRow
key={t.id}
className={`group flex items-center justify-between px-2 py-1 rounded cursor-pointer ${
activeView === 'tag' && selectedTagId === t.id
? 'bg-evernote-sidebarActive text-white font-medium'
: 'hover:bg-evernote-sidebarHover text-gray-300'
}`}
>
<button
type="button"
onClick={() => {
setActiveView('tag');
setSelectedTagId(t.id);
setMobileMenuOpen(false);
setMobilePane('list');
}}
className="flex items-center gap-2 flex-1 truncate text-left"
>
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: t.color || '#00a82d' }}
/>
<span className="truncate">{t.name}</span>
</button>
<div className="flex items-center gap-1">
<span className="text-[10px] text-gray-500">{t.noteCount ?? 0}</span>
<button
type="button"
onClick={async (e) => {
e.stopPropagation();
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 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" />
</button>
</div>
</div>
tag={t}
active={activeView === 'tag' && selectedTagId === t.id}
onSelect={() => {
setActiveView('tag');
setSelectedTagId(t.id);
setMobileMenuOpen(false);
setMobilePane('list');
}}
onDropNote={(noteId) => handleTagNoteById(noteId, t.id, t.name)}
onRefresh={fetchSidebar}
onDeleted={() => {
if (selectedTagId === t.id) {
setSelectedTagId('');
setActiveView('all');
}
fetchNotesList();
}}
/>
))}
{sidebar.tags.length === 0 && (
<div className="text-[11px] text-gray-500 px-2 py-1 italic">
@@ -1963,27 +1957,17 @@ export default function EvercloneWorkspace() {
</div>
{/* Trash */}
<button
type="button"
onClick={() => {
<SidebarTrashRow
active={activeView === 'trash'}
count={sidebar.counts.trash}
collapsed={sidebarCollapsed}
onSelect={() => {
setActiveView('trash');
setMobileMenuOpen(false);
setMobilePane('list');
}}
className={`w-full flex items-center justify-between px-2.5 py-2 rounded-lg transition ${
activeView === 'trash'
? 'bg-evernote-sidebarActive text-white font-medium'
: 'hover:bg-evernote-sidebarHover text-gray-300'
}`}
>
<span className="flex items-center gap-2.5">
<Trash2 className="w-4 h-4 text-gray-400 shrink-0" />
{!sidebarCollapsed && <span>Trash</span>}
</span>
{!sidebarCollapsed && sidebar.counts.trash > 0 && (
<span className="text-[11px] text-gray-500">{sidebar.counts.trash}</span>
)}
</button>
onDropNote={(noteId) => handleTrashNoteById(noteId)}
/>
{/* Divider + Settings */}
<div className="pt-2 mt-2 border-t border-neutral-800">
@@ -2804,6 +2788,8 @@ export default function EvercloneWorkspace() {
draggable={!n.isTrashed}
onDragStart={(e) => {
e.dataTransfer.setData('text/everclone-note-id', n.id);
e.dataTransfer.setData('text/plain', n.id);
e.dataTransfer.effectAllowed = 'move';
}}
onClick={() => openNoteInEditor(n.id)}
className={`group/card cursor-pointer rounded-xl border transition-colors duration-150 flex flex-col overflow-hidden shadow-xs ${
@@ -2971,6 +2957,8 @@ export default function EvercloneWorkspace() {
draggable={!n.isTrashed}
onDragStart={(e) => {
e.dataTransfer.setData('text/everclone-note-id', n.id);
e.dataTransfer.setData('text/plain', n.id);
e.dataTransfer.effectAllowed = 'move';
}}
onClick={() => openNoteInEditor(n.id)}
style={{
@@ -3869,6 +3857,11 @@ function SidebarStackRow({
return (
<div
onDragEnter={() => {
if (!isExpanded) {
onToggleExpand();
}
}}
className={`group relative flex items-center justify-between px-2 py-1.5 rounded cursor-pointer ${
isActive
? 'bg-evernote-sidebarActive text-white font-medium'
@@ -3982,19 +3975,37 @@ function NotebookRow({
}) {
const [menuOpen, setMenuOpen] = useState(false);
const [dragOver, setDragOver] = useState(false);
const dragDepth = useRef(0);
return (
<div
onDragOver={(e) => {
onDragEnter={(e) => {
if (onDropNote) {
e.preventDefault();
dragDepth.current++;
setDragOver(true);
}
}}
onDragLeave={() => setDragOver(false)}
onDragOver={(e) => {
if (onDropNote) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (!dragOver) setDragOver(true);
}
}}
onDragLeave={() => {
dragDepth.current--;
if (dragDepth.current <= 0) {
dragDepth.current = 0;
setDragOver(false);
}
}}
onDrop={(e) => {
dragDepth.current = 0;
setDragOver(false);
const droppedNoteId = e.dataTransfer.getData('text/everclone-note-id');
const droppedNoteId =
e.dataTransfer.getData('text/everclone-note-id') ||
e.dataTransfer.getData('text/plain');
if (droppedNoteId && onDropNote) {
e.preventDefault();
onDropNote(droppedNoteId);
@@ -4002,7 +4013,7 @@ function NotebookRow({
}}
className={`group relative flex items-center justify-between px-2 py-1.5 rounded cursor-pointer transition ${
dragOver
? 'bg-emerald-900/70 ring-1 ring-evernote-green text-white'
? 'bg-emerald-900/80 ring-2 ring-evernote-green text-white font-medium shadow-sm'
: active
? 'bg-evernote-sidebarActive text-white font-medium'
: 'hover:bg-evernote-sidebarHover text-gray-300'
@@ -4015,6 +4026,11 @@ function NotebookRow({
>
<Book className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="truncate">{nb.name}</span>
{dragOver && (
<span className="text-[10px] text-emerald-300 font-normal ml-1">
Move here
</span>
)}
{nb.isShared && (
<span
title={nb.isOwner === false ? `Shared with you (${nb.shareRole ?? 'read'})` : 'Shared notebook'}
@@ -4169,3 +4185,191 @@ function NotebookRow({
</div>
);
}
function SidebarTagRow({
tag,
active,
onSelect,
onDropNote,
onRefresh,
onDeleted,
}: {
tag: { id: string; name: string; color?: string | null; noteCount?: number };
active: boolean;
onSelect: () => void;
onDropNote?: (noteId: string) => void;
onRefresh: () => void;
onDeleted?: () => void;
}) {
const [dragOver, setDragOver] = useState(false);
const dragDepth = useRef(0);
return (
<div
onDragEnter={(e) => {
if (onDropNote) {
e.preventDefault();
dragDepth.current++;
setDragOver(true);
}
}}
onDragOver={(e) => {
if (onDropNote) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
if (!dragOver) setDragOver(true);
}
}}
onDragLeave={() => {
dragDepth.current--;
if (dragDepth.current <= 0) {
dragDepth.current = 0;
setDragOver(false);
}
}}
onDrop={(e) => {
dragDepth.current = 0;
setDragOver(false);
const droppedNoteId =
e.dataTransfer.getData('text/everclone-note-id') ||
e.dataTransfer.getData('text/plain');
if (droppedNoteId && onDropNote) {
e.preventDefault();
onDropNote(droppedNoteId);
}
}}
className={`group flex items-center justify-between px-2 py-1 rounded cursor-pointer transition ${
dragOver
? 'bg-emerald-900/80 ring-2 ring-evernote-green text-white font-medium shadow-sm'
: active
? '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"
>
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: tag.color || '#00a82d' }}
/>
<span className="truncate">{tag.name}</span>
{dragOver && (
<span className="text-[10px] text-emerald-300 font-normal ml-1">
+ Tag
</span>
)}
</button>
<div className="flex items-center gap-1">
<span className="text-[10px] text-gray-500">{tag.noteCount ?? 0}</span>
<button
type="button"
onClick={async (e) => {
e.stopPropagation();
const newName = window.prompt('Rename tag:', tag.name);
if (newName?.trim() && newName.trim() !== tag.name) {
await fetch('/api/tags', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: tag.id, name: newName.trim() }),
});
onRefresh();
}
}}
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 "${tag.name}"?`)) {
await fetch(`/api/tags?id=${tag.id}`, { method: 'DELETE' });
if (onDeleted) onDeleted();
onRefresh();
}
}}
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" />
</button>
</div>
</div>
);
}
function SidebarTrashRow({
active,
count,
collapsed,
onSelect,
onDropNote,
}: {
active: boolean;
count: number;
collapsed: boolean;
onSelect: () => void;
onDropNote: (noteId: string) => void;
}) {
const [dragOver, setDragOver] = useState(false);
const dragDepth = useRef(0);
return (
<button
type="button"
onClick={onSelect}
onDragEnter={(e) => {
e.preventDefault();
dragDepth.current++;
setDragOver(true);
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (!dragOver) setDragOver(true);
}}
onDragLeave={() => {
dragDepth.current--;
if (dragDepth.current <= 0) {
dragDepth.current = 0;
setDragOver(false);
}
}}
onDrop={(e) => {
dragDepth.current = 0;
setDragOver(false);
const droppedNoteId =
e.dataTransfer.getData('text/everclone-note-id') ||
e.dataTransfer.getData('text/plain');
if (droppedNoteId) {
e.preventDefault();
onDropNote(droppedNoteId);
}
}}
className={`w-full flex items-center justify-between px-2.5 py-2 rounded-lg transition ${
dragOver
? 'bg-red-950/80 ring-2 ring-red-500 text-red-200 font-medium shadow-sm'
: active
? 'bg-evernote-sidebarActive text-white font-medium'
: 'hover:bg-evernote-sidebarHover text-gray-300'
}`}
>
<span className="flex items-center gap-2.5">
<Trash2
className={`w-4 h-4 shrink-0 transition-colors ${
dragOver ? 'text-red-400' : 'text-gray-400'
}`}
/>
{!collapsed && <span>{dragOver ? 'Move to Trash' : 'Trash'}</span>}
</span>
{!collapsed && count > 0 && (
<span className="text-[11px] text-gray-500">{count}</span>
)}
</button>
);
}