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)} + + +
+ ) : ( + )}