Fix notebook drag highlight and add drag-to-tag and drag-to-trash support
This commit is contained in:
1 parent
0f24af880b
commit
2cc202ce03
2 files changed
+304
-87
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user