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
View File
@@ -240,6 +240,9 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<li>
<strong className="text-main">Ansicht „Tags & Filter“:</strong> Im oberen Workspace-Reiter findest du die Tag-Übersicht. Hier siehst du alle verwendeten Hashtags, kannst Fundstellen aus allen Dokumenten gebündelt durchsuchen und sogar eigene freie Notizen auf Tag-Seiten hinterlegen.
</li>
<li>
<strong className="text-main">Papierkorb:</strong> Gelöschte Notizen (auch aus gelöschten Ordnern) landen im „Papierkorb“ unten in der Ordnerleiste bzw. im mobilen Menü. Dort kannst du sie schreibgeschützt ansehen, wiederherstellen oder endgültig löschen. Solange eine Notiz im Papierkorb liegt, sehen sie auch die Personen nicht, mit denen sie geteilt war.
</li>
</ul>
</div>
),
@@ -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>
@@ -0,0 +1,141 @@
import React, { useState } from 'react';
import { ArrowLeft, Folder, RotateCcw, Trash2 } from 'lucide-react';
import { TrashedDocument } from '../../lib/types';
import { CountBadge } from '../../components/CountBadge';
interface TrashViewProps {
items: TrashedDocument[];
onOpen: (docId: string) => void;
onRestore: (docId: string) => Promise<void>;
onPurge: (docId: string) => Promise<void>;
onEmpty: () => Promise<void>;
onBackMobile: () => void;
}
const formatDeletedAt = (iso: string) => {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
};
// Deleted notes: open read-only, restore, or delete for good. Deleting for good always takes a second
// tap on the same button ("Wirklich löschen?"), which works the same with mouse and touch.
export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore, onPurge, onEmpty, onBackMobile }) => {
const [confirmId, setConfirmId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const run = async (id: string, action: () => Promise<void>) => {
setBusyId(id);
setError(null);
try {
await action();
setConfirmId(null);
} catch (err: any) {
setError(err.message || 'Aktion fehlgeschlagen');
} finally {
setBusyId(null);
}
};
return (
<div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-canvas">
<div className="w-full max-w-3xl space-y-4">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={onBackMobile}
className="lg:hidden btn-action h-11 w-11 flex items-center justify-center"
aria-label="Zurück zu den Notizen"
>
<ArrowLeft className="w-4 h-4" />
</button>
<Trash2 className="w-5 h-5 text-accent" />
<h1 className="text-xl font-bold text-main">Papierkorb</h1>
<CountBadge count={items.length} />
{items.length > 0 && (
<button
type="button"
disabled={busyId !== null}
onClick={() =>
confirmId === '__all__' ? run('__all__', onEmpty) : setConfirmId('__all__')
}
className={`ml-auto h-11 sm:h-8 px-3 text-xs font-semibold rounded-btn border transition-colors disabled:opacity-50 ${
confirmId === '__all__'
? 'bg-status-err/20 text-status-err border-status-err'
: 'btn-action text-status-err border-status-err/40'
}`}
>
{confirmId === '__all__' ? `Wirklich ${items.length} endgültig löschen?` : 'Papierkorb leeren'}
</button>
)}
</div>
<p className="text-xs text-muted">
Gelöschte Notizen bleiben hier, bis du sie wiederherstellst oder endgültig löschst. Für alle, mit denen sie geteilt
waren, sind sie bis dahin ausgeblendet.
</p>
{error && <p className="text-xs text-status-err">{error}</p>}
{items.length === 0 ? (
<div className="p-8 text-center text-sm text-faint border border-dashed border-border rounded-card">
Der Papierkorb ist leer.
</div>
) : (
<ul className="space-y-2">
{items.map((t) => (
<li
key={t.id}
className="p-3 bg-panel border border-border rounded-card flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3"
>
<button
type="button"
onClick={() => onOpen(t.id)}
className="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
title="Schreibgeschützt ansehen"
>
<span className="text-lg shrink-0">{t.icon || '📝'}</span>
<span className="min-w-0">
<span className="block text-sm font-semibold text-main truncate group-hover:text-accent">
{t.title || 'Unbenannte Notiz'}
</span>
<span className="flex flex-wrap items-center gap-x-2 text-[11px] text-muted">
<span className="inline-flex items-center gap-1 min-w-0">
<Folder className="w-3 h-3 shrink-0" />
<span className="truncate">{t.folderPath || 'Hauptebene'}</span>
</span>
<span>gelöscht {formatDeletedAt(t.deletedAt)}</span>
</span>
</span>
</button>
<div className="grid grid-cols-2 sm:flex gap-2 shrink-0">
<button
type="button"
disabled={busyId !== null}
onClick={() => run(t.id, () => onRestore(t.id))}
className="btn-action h-11 sm:h-8 px-3 text-xs inline-flex items-center justify-center gap-1.5 disabled:opacity-50"
>
<RotateCcw className="w-3.5 h-3.5 text-accent" />
Wiederherstellen
</button>
<button
type="button"
disabled={busyId !== null}
onClick={() => (confirmId === t.id ? run(t.id, () => onPurge(t.id)) : setConfirmId(t.id))}
className={`h-11 sm:h-8 px-3 text-xs rounded-btn border inline-flex items-center justify-center gap-1.5 transition-colors disabled:opacity-50 ${
confirmId === t.id
? 'bg-status-err/20 text-status-err border-status-err font-semibold'
: 'btn-action text-status-err border-status-err/40'
}`}
>
<Trash2 className="w-3.5 h-3.5" />
{confirmId === t.id ? 'Wirklich löschen?' : 'Endgültig löschen'}
</button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
};
@@ -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}
+18
View File
@@ -3,6 +3,7 @@ import {
AuthStatus,
DashboardStats,
Document,
TrashedDocument,
DocumentBlock,
DocShare,
KanbanColumn,
@@ -647,10 +648,27 @@ export const api = {
});
},
/** Moves the note to the trash (owner) or leaves a shared note (others). */
async deleteDocument(id: string): Promise<void> {
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
},
async getTrash(): Promise<TrashedDocument[]> {
return request<TrashedDocument[]>('/api/v1/documents/trash');
},
async restoreDocument(id: string): Promise<void> {
await request(`/api/v1/documents/${id}/restore`, { method: 'POST' });
},
async purgeDocument(id: string): Promise<void> {
await request(`/api/v1/documents/${id}/permanent`, { method: 'DELETE' });
},
async emptyTrash(): Promise<{ deleted: number }> {
return request<{ deleted: number }>('/api/v1/documents/trash', { method: 'DELETE' });
},
async moveDocument(id: string, folderPath: string): Promise<Document> {
const d = await request<Document>(`/api/v1/documents/${id}/move`, {
method: 'PUT',
+10
View File
@@ -232,6 +232,16 @@ export interface DocShare {
createdAt: string;
}
/** A note in the trash (GET /documents/trash). */
export interface TrashedDocument {
id: string;
title: string;
icon: string;
folderPath: string;
deletedAt: string;
isOwner: boolean;
}
export interface Document {
id: string;
userId: string;