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:
sandrodachandClaude Opus 5.5 committed 2026-10-29 07:57:42 +01:00
1 parent 344f5e4ab2
commit f69f74fdc7
15 files changed
+649 -40

No files matched your search

@@ -3,6 +3,7 @@ import {
Pin,
Users,
Trash2,
RotateCcw,
Download,
FileText,
Loader2,
@@ -36,6 +37,7 @@ export interface DocumentEditorProps {
initialFocusedBlockId?: string | null;
onDocMetadataChange: (updated: Document) => void;
onDeleteDoc: (docId: string) => void;
onRestoreDoc?: (docId: string) => void;
onOpenShareModal: (doc: Document) => void;
onSelectTag: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
@@ -179,6 +181,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
initialFocusedBlockId,
onDocMetadataChange,
onDeleteDoc,
onRestoreDoc,
onOpenShareModal,
onSelectTag,
onWikiLinkClick,
@@ -244,7 +247,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
}, [title, doc?.title, blocks, zoomedBlockId]);
// Owner and editors may edit; the role comes from the server via the sync engine
const inTrash = Boolean(doc?.isArchived);
const canEdit = useMemo(() => {
if (doc?.isArchived) return false;
if (engineState?.role) return engineState.role === 'owner' || engineState.role === 'editor';
if (!doc) return false;
return Boolean(doc.isOwner || doc.permission === 'owner' || doc.permission === 'write');
@@ -770,7 +775,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{/* Who else has this note open */}
<PresenceAvatars people={otherPeople} avatars={avatarsById} />
{/* Sharing Section */}
{doc && (() => {
{doc && !inTrash && (() => {
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
const sharesCount = doc.sharedWith?.length || 0;
const isShared = sharesCount > 0 || !isOwner || Boolean(doc.sharedFolderId);
@@ -814,7 +819,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
);
})()}
<button
{!inTrash && <button
type="button"
onClick={handleTogglePin}
className={`btn-icon ${
@@ -825,7 +830,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
title={doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}
>
<Pin className="w-3.5 h-3.5" />
</button>
</button>}
<button
type="button"
@@ -849,8 +854,26 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
</div>
</div>
{/* In the trash: read-only with a way back */}
{inTrash && (
<div className="flex flex-wrap items-center gap-2 px-4 sm:px-6 py-2 bg-status-err/10 border-b border-status-err/25 text-status-err text-xs">
<Trash2 className="w-3.5 h-3.5 shrink-0" />
<span className="flex-1 min-w-0">Diese Notiz liegt im Papierkorb und ist schreibgeschützt.</span>
{onRestoreDoc && (
<button
type="button"
onClick={() => onRestoreDoc(docId)}
className="btn-action h-11 sm:h-7 px-3 text-xs text-main inline-flex items-center gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5 text-accent" />
Wiederherstellen
</button>
)}
</div>
)}
{/* Read-only notification banner */}
{!canEdit && (
{!canEdit && !inTrash && (
<div className="flex items-center gap-2 px-4 sm:px-6 py-2 bg-status-warn/10 border-b border-status-warn/25 text-status-warn text-xs">
<Lock className="w-3.5 h-3.5 shrink-0" />
<span>Dieses Dokument wurde schreibgeschützt mit dir geteilt (Nur Lesen).</span>