feat(workspace): trash for notes
Deleting a note (or its folder) moves it to the trash instead of erasing it. Trashed notes leave the tree, search, export and stats and are hidden from everyone they were shared with; the owner sees them read-only with a restore banner. New endpoints list, restore, purge and empty the trash; restoring recreates a deleted folder. Sidebar entry and mobile menu item with counter, list view with two-tap permanent delete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
344f5e4ab2
commit
f69f74fdc7
15 files changed
+649
-40
No files matched your search
@@ -36,6 +36,7 @@ import {
|
||||
TagDetails,
|
||||
TagSummary,
|
||||
Team,
|
||||
TrashedDocument,
|
||||
UnifiedShare,
|
||||
User,
|
||||
} from '../../lib/types';
|
||||
@@ -53,6 +54,8 @@ import { FormSheet } from '../../components/FormSheet';
|
||||
import { sendDocOps } from '../../lib/docSync/oneoff';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
|
||||
import { TrashView } from './TrashView';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
import { extractHashtags } from '../../lib/hashtags';
|
||||
|
||||
interface WorkspaceViewProps {
|
||||
@@ -63,7 +66,7 @@ interface WorkspaceViewProps {
|
||||
onOpenTask?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags';
|
||||
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags' | 'trash';
|
||||
|
||||
interface FolderTreeNode {
|
||||
name: string;
|
||||
@@ -307,6 +310,15 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
const [targetBlockId, setTargetBlockId] = useState<string | null>(initialBlockId || null);
|
||||
const [newlyCreatedDocId, setNewlyCreatedDocId] = useState<string | null>(null);
|
||||
const [subView, setSubView] = useState<WorkspaceSubView>('editor');
|
||||
const [trash, setTrash] = useState<TrashedDocument[]>([]);
|
||||
// Bumped after a restore so the open editor reloads the note without the trash banner
|
||||
const [editorNonce, setEditorNonce] = useState(0);
|
||||
const reloadTrash = useCallback(() => {
|
||||
api.getTrash().then(setTrash).catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
reloadTrash();
|
||||
}, [reloadTrash]);
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||
const [tagDetails, setTagDetails] = useState<TagDetails | null>(null);
|
||||
const [tagNoteDraft, setTagNoteDraft] = useState<string>('');
|
||||
@@ -466,14 +478,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
const delId = msg.payload.id as string;
|
||||
setDocs((prev) => prev.filter((d) => d.id !== delId));
|
||||
setSelectedDocId((prev) => (prev === delId ? null : prev));
|
||||
reloadTrash();
|
||||
} else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') {
|
||||
api.getFolders().then(setFolders).catch(() => {});
|
||||
api.getSharedFolders().then(setSharedFolders).catch(() => {});
|
||||
api.getDocuments().then(setDocs).catch(() => {});
|
||||
reloadTrash();
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
}, [currentUser]);
|
||||
}, [currentUser, reloadTrash]);
|
||||
|
||||
const handleCreateDocument = useCallback(
|
||||
async (folderPath = '') => {
|
||||
@@ -524,6 +538,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
setMobileShowDetail(true);
|
||||
}, []);
|
||||
|
||||
const openTrash = useCallback(() => {
|
||||
reloadTrash();
|
||||
setSubView('trash');
|
||||
setMobileShowDetail(true);
|
||||
}, [reloadTrash]);
|
||||
|
||||
// Register mobile FAB actions
|
||||
useEffect(() => {
|
||||
setPrimaryAction(() => handleCreateDocument(''));
|
||||
@@ -545,6 +565,13 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
icon: <FolderPlus className="w-4 h-4" />,
|
||||
onClick: () => setShowNewFolderModal(true),
|
||||
},
|
||||
{
|
||||
id: 'trash',
|
||||
label: 'Papierkorb',
|
||||
icon: <Trash2 className="w-4 h-4" />,
|
||||
badge: trash.length,
|
||||
onClick: openTrash,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -552,7 +579,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
setGroups([]);
|
||||
setPrimaryAction(undefined);
|
||||
};
|
||||
}, [handleCreateDocument, setGroups, setPrimaryAction]);
|
||||
}, [handleCreateDocument, setGroups, setPrimaryAction, trash.length, openTrash]);
|
||||
|
||||
const handleDeleteDocument = useCallback(
|
||||
async (docId: string) => {
|
||||
@@ -567,13 +594,47 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
});
|
||||
setMobileShowDetail(false);
|
||||
onStatsChange();
|
||||
reloadTrash();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[onStatsChange, selectedDocId]
|
||||
[onStatsChange, selectedDocId, reloadTrash]
|
||||
);
|
||||
|
||||
const handleOpenTrashedDoc = (docId: string) => {
|
||||
setSelectedDocId(docId);
|
||||
setTargetBlockId(null);
|
||||
setNewlyCreatedDocId(null);
|
||||
setSubView('editor');
|
||||
setMobileShowDetail(true);
|
||||
};
|
||||
|
||||
const handleRestoreDocument = useCallback(
|
||||
async (docId: string) => {
|
||||
await api.restoreDocument(docId);
|
||||
const [freshDocs, freshFolders] = await Promise.all([api.getDocuments(), api.getFolders()]);
|
||||
setDocs(freshDocs);
|
||||
setFolders(freshFolders);
|
||||
setEditorNonce((n) => n + 1);
|
||||
reloadTrash();
|
||||
onStatsChange();
|
||||
},
|
||||
[reloadTrash, onStatsChange]
|
||||
);
|
||||
|
||||
const handlePurgeDocument = async (docId: string) => {
|
||||
await api.purgeDocument(docId);
|
||||
if (selectedDocId === docId) setSelectedDocId(null);
|
||||
reloadTrash();
|
||||
};
|
||||
|
||||
const handleEmptyTrash = async () => {
|
||||
await api.emptyTrash();
|
||||
if (selectedDocId && trash.some((t) => t.id === selectedDocId)) setSelectedDocId(null);
|
||||
reloadTrash();
|
||||
};
|
||||
|
||||
const handleDocMetadataChange = useCallback((updated: Document) => {
|
||||
setDocs((prev) => prev.map((d) => (d.id === updated.id ? { ...d, ...updated } : d)));
|
||||
}, []);
|
||||
@@ -615,6 +676,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}
|
||||
}
|
||||
onStatsChange();
|
||||
reloadTrash();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -1257,7 +1319,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
{/* LEFT MASTER SIDEBAR */}
|
||||
<aside
|
||||
className={`${
|
||||
subView !== 'editor' || isSidebarCollapsed
|
||||
(subView !== 'editor' && subView !== 'trash') || isSidebarCollapsed
|
||||
? 'hidden'
|
||||
: mobileShowDetail
|
||||
? 'hidden lg:flex'
|
||||
@@ -1469,6 +1531,23 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trash at the bottom of the tree */}
|
||||
<div className="p-2 border-t border-border shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openTrash}
|
||||
className={`w-full flex items-center gap-2 px-2 h-11 sm:h-8 rounded-btn text-xs border transition-colors cursor-pointer ${
|
||||
subView === 'trash'
|
||||
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
||||
: 'border-transparent text-muted hover:text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<span className="flex-1 text-left">Papierkorb</span>
|
||||
<CountBadge count={trash.length} />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* RIGHT DETAIL AREA (STRICTLY ISOLATED BY KEY) */}
|
||||
@@ -1477,9 +1556,21 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
subView !== 'editor' || mobileShowDetail || isSidebarCollapsed ? 'flex' : 'hidden lg:flex'
|
||||
} flex-1 flex-col min-w-0 h-full overflow-hidden bg-canvas`}
|
||||
>
|
||||
{subView === 'editor' && selectedDocId ? (
|
||||
{subView === 'trash' ? (
|
||||
<TrashView
|
||||
items={trash}
|
||||
onOpen={handleOpenTrashedDoc}
|
||||
onRestore={handleRestoreDocument}
|
||||
onPurge={handlePurgeDocument}
|
||||
onEmpty={handleEmptyTrash}
|
||||
onBackMobile={() => {
|
||||
setSubView('editor');
|
||||
setMobileShowDetail(false);
|
||||
}}
|
||||
/>
|
||||
) : subView === 'editor' && selectedDocId ? (
|
||||
<DocumentEditor
|
||||
key={selectedDocId}
|
||||
key={`${selectedDocId}:${editorNonce}`}
|
||||
docId={selectedDocId}
|
||||
currentUser={currentUser}
|
||||
initialDoc={selectedDocObj}
|
||||
@@ -1488,6 +1579,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
initialFocusedBlockId={targetBlockId}
|
||||
onDocMetadataChange={handleDocMetadataChange}
|
||||
onDeleteDoc={handleDeleteDocument}
|
||||
onRestoreDoc={handleRestoreDocument}
|
||||
onOpenShareModal={openDocShareModal}
|
||||
onSelectTag={handleSelectTag}
|
||||
onWikiLinkClick={handleWikiLinkClick}
|
||||
|
||||
Reference in new issue
Block a user