From b26b07cfea98a8ab9af14c3001b3e4ce81657ae2 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 2 Nov 2026 19:36:22 +0100 Subject: [PATCH 1/2] feat(workspace): deleted folders go to the trash as a whole, after a confirmation A deleted personal folder is kept in trashed_folders with its subfolders; its notes are trashed and tied to it. The trash lists the folder as one entry with its note count; restoring brings back the folder tree (empty subfolders too) and all notes, deleting for good, emptying the trash and the notes trash delay remove it with its notes. Folders in shared folders keep the old way. Deleting a folder now asks first, in a small popover at the click (ConfirmPopover, documented in ui-patterns). Co-Authored-By: Claude Opus 5.5 --- Doku/api/rest.md | 6 +- Doku/ui-patterns.md | 7 + frontend/src/components/ConfirmPopover.tsx | 81 +++++++++ .../__tests__/ConfirmPopover.test.tsx | 25 +++ frontend/src/features/workspace/TrashView.tsx | 33 +++- .../src/features/workspace/WorkspaceView.tsx | 63 ++++++- .../workspace/__tests__/TrashView.test.tsx | 29 +++- frontend/src/lib/api.ts | 8 + frontend/src/lib/types.ts | 4 + internal/api/handlers_folders.go | 32 ++++ internal/api/server.go | 2 + internal/storage/account.go | 1 + internal/storage/folders.go | 18 ++ internal/storage/migrations.go | 1 + internal/storage/retention.go | 7 + internal/storage/sqlite_test.go | 6 +- internal/storage/trash.go | 164 +++++++++++++++++- internal/storage/trash_test.go | 102 +++++++++++ 18 files changed, 564 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/ConfirmPopover.tsx create mode 100644 frontend/src/components/__tests__/ConfirmPopover.test.tsx diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 925a213..7bd5ba6 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -196,7 +196,9 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein | Methode | Pfad | Zweck | |---|---|---| -| GET/POST/DELETE | `/folders` | Ordnerliste, anlegen (`path`), löschen | +| GET/POST/DELETE | `/folders` | Ordnerliste, anlegen (`path`), löschen; ein eigener Ordner kommt mit Unterordnern und Notizen als ein Eintrag in den Papierkorb | +| POST | `/folders/trash/{id}/restore` | Ordner aus dem Papierkorb wiederherstellen (mit allen Unterordnern, auch leeren, und Notizen) → `restoredDocIds` | +| DELETE | `/folders/trash/{id}` | Ordner im Papierkorb samt Notizen endgültig löschen | | POST | `/folders/move` | `sourcePath`, `targetParentPath` | | GET/POST | `/workspace/folders/shared` | Geteilte Ordner; anlegen (`name`, `mountPath`) | | GET/DELETE | `/workspace/folders/shared/{id}` | Abruf; Löschen | @@ -207,7 +209,7 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein | PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) | | GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`; `icon: ""` = ohne Symbol, fehlt es: 🗒️) | | GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern von `title`, `icon`, `folderPath`, `isPinned` (Blöcke → `400`, sie laufen über `/docs/{id}/sync`); Löschen = in den Papierkorb (Eigentümer) bzw. Freigabe verlassen (andere) | -| GET/DELETE | `/documents/trash` | Papierkorb auflisten (`id`, `title`, `icon`, `folderPath`, `deletedAt`, `isOwner`) bzw. leeren | +| GET/DELETE | `/documents/trash` | Papierkorb auflisten (`id`, `kind` `note`/`folder`, `title`, `icon`, `folderPath`, `deletedAt`, `isOwner`, bei Ordnern `noteCount`; Notizen eines gelöschten Ordners stecken in dessen Eintrag) bzw. leeren (Notizen und Ordner) | | GET | `/documents/{id}/backlinks` | Notizen mit `[[Titel]]`-Link auf diese Notiz (nur sichtbare, ohne Papierkorb): `docId`, `title`, `icon`, `folderPath`, bis zu 5 `blocks` (`id`, `content`), `more` | | POST | `/documents/{id}/restore` | Aus dem Papierkorb zurückholen (fehlender Ordner wird neu angelegt) | | DELETE | `/documents/{id}/permanent` | Notiz im Papierkorb endgültig löschen | diff --git a/Doku/ui-patterns.md b/Doku/ui-patterns.md index 6aed6d4..75b5153 100644 --- a/Doku/ui-patterns.md +++ b/Doku/ui-patterns.md @@ -50,6 +50,13 @@ Komponente: [FormSheet.tsx](../frontend/src/components/FormSheet.tsx), Tastatur- - Escape und Klick auf den Hintergrund schließen. Genutzt für neue Aufgabenliste, neue Einkaufsliste, neue Notiz und neuen Ordner; das Emoji-Sheet nutzt denselben Tastatur-Abstand. - **Icon-Feld:** [EmojiIconButton.tsx](../frontend/src/components/EmojiIconButton.tsx) statt eines Textfelds: zeigt das Emoji (ohne Icon ein dezentes „Icon wählen“) und öffnet denselben Emoji-Auswähler wie der Notiz-Kopf, inklusive „Entfernen“. Im Dialog liegt der Auswähler per Portal über der Seite; Escape schließt nur ihn. +## Bestätigung an der Mausposition + +Komponente: [ConfirmPopover.tsx](../frontend/src/components/ConfirmPopover.tsx) + +- Für Rückfragen zu einer einzelnen Aktion, z. B. Ordner löschen: ein kleines Fenster direkt an der Stelle des Klicks (unten rechts vom Mauszeiger, am Fensterrand nach oben bzw. links geklappt), kein Dialog in der Bildmitte. +- „Abbrechen“ und die Aktion (gefährliche Aktionen rot mit `danger`); die Aktion hat den Fokus, Enter bestätigt, Escape oder ein Klick daneben schließt. Per Tastatur ausgelöst öffnet es am Knopf. Am Handy sind die Knöpfe 44 px hoch. + ## Hinweis mit „Rückgängig“ - Kurzer Hinweis unten mittig (`role="status"`, Fläche `bg-panel-elevated`), am Handy über der unteren Leiste; mit Aktion bleibt er 8–10 s, sonst 3–4 s. diff --git a/frontend/src/components/ConfirmPopover.tsx b/frontend/src/components/ConfirmPopover.tsx new file mode 100644 index 0000000..c0350fb --- /dev/null +++ b/frontend/src/components/ConfirmPopover.tsx @@ -0,0 +1,81 @@ +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +interface ConfirmPopoverProps { + // Where the click was (clientX/clientY); the popover opens right there, kept inside the window + x: number; + y: number; + message: React.ReactNode; + confirmLabel: string; + danger?: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +const MARGIN = 8; + +// Small confirmation right at the mouse (Doku/ui-patterns.md): Enter or the button confirms, Escape, a +// click outside or "Abbrechen" closes it. +export const ConfirmPopover: React.FC = ({ x, y, message, confirmLabel, danger, onConfirm, onCancel }) => { + const ref = useRef(null); + const confirmRef = useRef(null); + const [pos, setPos] = useState({ left: x, top: y, ready: false }); + + // Below and right of the pointer; flipped above or to the left when it would leave the window + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + const { width, height } = el.getBoundingClientRect(); + let left = x - 12; + let top = y + MARGIN; + if (left + width > window.innerWidth - MARGIN) left = window.innerWidth - width - MARGIN; + if (top + height > window.innerHeight - MARGIN) top = y - height - MARGIN; + setPos({ left: Math.max(MARGIN, left), top: Math.max(MARGIN, top), ready: true }); + confirmRef.current?.focus(); + }, [x, y]); + + useEffect(() => { + const onDown = (e: Event) => { + if (ref.current && !ref.current.contains(e.target as Node)) onCancel(); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onCancel(); + }; + document.addEventListener('pointerdown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('pointerdown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [onCancel]); + + return createPortal( +
+
{message}
+
+ + +
+
, + document.body + ); +}; diff --git a/frontend/src/components/__tests__/ConfirmPopover.test.tsx b/frontend/src/components/__tests__/ConfirmPopover.test.tsx new file mode 100644 index 0000000..7d8939f --- /dev/null +++ b/frontend/src/components/__tests__/ConfirmPopover.test.tsx @@ -0,0 +1,25 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ConfirmPopover } from '../ConfirmPopover'; + +describe('ConfirmPopover', () => { + it('confirms with its button, which has the focus (Enter)', () => { + const onConfirm = vi.fn(); + render(); + const button = screen.getByRole('button', { name: 'In den Papierkorb' }); + expect(document.activeElement).toBe(button); + fireEvent.click(button); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('closes without confirming on Abbrechen, Escape or a click outside', () => { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Abbrechen' })); + fireEvent.keyDown(document, { key: 'Escape' }); + fireEvent.pointerDown(document.body); + expect(onCancel).toHaveBeenCalledTimes(3); + expect(onConfirm).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/features/workspace/TrashView.tsx b/frontend/src/features/workspace/TrashView.tsx index 5a1e052..4982268 100644 --- a/frontend/src/features/workspace/TrashView.tsx +++ b/frontend/src/features/workspace/TrashView.tsx @@ -7,14 +7,15 @@ import { formatDeletedAt } from '../../lib/due'; interface TrashViewProps { items: TrashedDocument[]; onOpen: (docId: string) => void; - onRestore: (docId: string) => Promise; - onPurge: (docId: string) => Promise; + onRestore: (item: TrashedDocument) => Promise; + onPurge: (item: TrashedDocument) => Promise; onEmpty: () => Promise; onBackMobile: () => void; } -// 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. +// Deleted notes and folders: open a note read-only, restore, or delete for good. A folder is one entry +// with its notes and comes back as a whole. 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 = ({ items, onOpen, onRestore, onPurge, onEmpty, onBackMobile }) => { const [confirmId, setConfirmId] = useState(null); const [busyId, setBusyId] = useState(null); @@ -66,8 +67,8 @@ export const TrashView: React.FC = ({ items, onOpen, onRestore, )}

- 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. + Gelöschte Notizen und Ordner bleiben hier, bis du sie wiederherstellst oder endgültig löschst. Ein Ordner kommt mit + allen Unterordnern und Notizen zurück. Für alle, mit denen etwas geteilt war, ist es bis dahin ausgeblendet.

{error &&

{error}

} @@ -82,6 +83,21 @@ export const TrashView: React.FC = ({ items, onOpen, onRestore, 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" > + {t.kind === 'folder' ? ( +
+ + + {t.title} + + + Ordner · {t.noteCount === 1 ? '1 Notiz' : `${t.noteCount || 0} Notizen`} + + {t.folderPath && in {t.folderPath}} + gelöscht {formatDeletedAt(t.deletedAt)} + + +
+ ) : ( + )}