diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b132fb..b92a6c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.61.0] - 2026-11-02 + +### Added +- Notizen: Ein gelöschter Ordner landet als ein Eintrag im Papierkorb („Ordner · 3 Notizen“) und lässt sich mit allen Unterordnern, auch leeren, und Notizen wiederherstellen. Endgültig löschen, Papierkorb leeren und die Aufbewahrungsfrist gelten für den Ordner samt Inhalt. Ordner in geteilten Ordnern verhalten sich wie bisher. +- Notizen: Beim Löschen eines Ordners erscheint eine kurze Rückfrage direkt an der Klickstelle (Enter bestätigt, Escape bricht ab). + +### Fixed +- Notizen: Leere Unterordner eines gelöschten Ordners waren endgültig verloren. + ## [0.60.0] - 2026-11-02 ### Changed @@ -878,7 +887,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.60.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.61.0...dev +[0.61.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.60.0...v0.61.0 [0.60.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.59.3...v0.60.0 [0.59.3]: https://git.dsit.me/sandrodach/MindSift/compare/v0.59.2...v0.59.3 [0.59.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.59.1...v0.59.2 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/README.md b/README.md index 8e9de70..161d0d7 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.60.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.61.0-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2f5f1bd..867e475 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.60.0", + "version": "0.61.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.60.0", + "version": "0.61.0", "license": "AGPL-3.0-only", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index 4ef3748..5e77fe5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.60.0", + "version": "0.61.0", "license": "AGPL-3.0-only", "type": "module", "scripts": { 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)} + + +
+ ) : ( + )}