fix(workspace): hidden bullet actions on phones, no second extract, notice layout
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m20s

- The floating bullet actions only react to hover on devices with a real
  pointer; a tap on a phone made the invisible buttons clickable.
- A bullet that is only a note link (plus hashtags) can no longer be
  extracted: button and /extract are hidden, the server answers 400.
- Extract notice on phones sits above the bottom navigation, text on
  top (two lines max) with the actions below.
- Release v0.26.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-31 19:56:32 +01:00
1 parent fdd6e38065
commit 68bc4d279c
12 files changed
+78 -27

No files matched your search

+9 -1
View File
@@ -6,6 +6,13 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased]
## [0.26.1] - 2026-10-31
### Fixed
- Notizen am Handy: Ein Tipp rechts neben einen Punkt löste die unsichtbaren Knöpfe aus (In eigene Notiz umwandeln, Fokus, Löschen …). Die Knöpfe reagieren jetzt nur noch, wenn sie zu sehen sind, also beim Bearbeiten des Punkts.
- Notizen: Ein Punkt, der schon in eine eigene Notiz umgewandelt wurde (nur noch der Link, ggf. mit Hashtags), lässt sich nicht noch einmal umwandeln; der Knopf und `/extract` fehlen dort. Bisher entstand dabei eine zweite Notiz, die nur den Link enthielt.
- Hinweis nach dem Umwandeln am Handy („Öffnen“, „Rückgängig“): sitzt jetzt über der unteren Leiste statt am Bildschirmrand, Text oben (höchstens zwei Zeilen), Knöpfe darunter.
## [0.26.0] - 2026-10-31
### Added
@@ -527,7 +534,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.26.0...dev
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.26.1...dev
[0.26.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.26.0...v0.26.1
[0.26.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.25.1...v0.26.0
[0.25.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.25.0...v0.25.1
[0.25.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.24.2...v0.25.0
+1 -1
View File
@@ -127,7 +127,7 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
| POST | `/documents/{id}/blocks/{blockId}/enhanced-task` | Aus einem Block eine verknüpfte Aufgabe machen (`content`, `checked`) |
| GET/POST | `/documents/{id}/shares` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) |
| DELETE | `/documents/{id}/shares/{userId}` | Notiz-Freigabe entfernen |
| POST | `/documents/{id}/extract` | `{blockId, clientId}`: legt aus den Unterpunkten des Punkts eine eigene Notiz an (Titel = Text ohne Hashtags, gleicher Ordner, Freigaben übernommen, fremder Besitzer bekommt Schreibrecht) → `{document, link}`. Die Ursprungsnotiz ändert der Server nicht; der Editor ersetzt den Ast selbst durch `link` (normale eigene Änderung, Strg+Z möglich). Schreibrecht nötig; `409`, wenn jemand anderes im Ast schreibt |
| POST | `/documents/{id}/extract` | `{blockId, clientId}`: legt aus den Unterpunkten des Punkts eine eigene Notiz an (Titel = Text ohne Hashtags, gleicher Ordner, Freigaben übernommen, fremder Besitzer bekommt Schreibrecht) → `{document, link}`. Die Ursprungsnotiz ändert der Server nicht; der Editor ersetzt den Ast selbst durch `link` (normale eigene Änderung, Strg+Z möglich). Schreibrecht nötig; `409`, wenn jemand anderes im Ast schreibt; `400`, wenn der Punkt nur noch ein Link auf eine Notiz ist (ggf. mit Hashtags) |
| GET | `/push/key` | `{enabled, publicKey}` – öffentlicher VAPID-Schlüssel für `pushManager.subscribe` (in der Demo `enabled: false`) |
| GET | `/push/devices` | Eigene Geräte mit Push → `{devices: [{id, endpoint, device, createdAt}]}` |
| POST | `/push/subscribe` | `{endpoint, keys: {p256dh, auth}, device}` – Gerät anmelden (nur `https`-Endpunkte) |
+1 -1
View File
@@ -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.26.0-16a34a?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/Version-v0.26.1-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.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mindsift-frontend",
"version": "0.26.0",
"version": "0.26.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mindsift-frontend",
"version": "0.26.0",
"version": "0.26.1",
"license": "Unlicense",
"dependencies": {
"@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.26.0",
"version": "0.26.1",
"license": "Unlicense",
"type": "module",
"scripts": {
@@ -1372,23 +1372,28 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{notice && (
<div
role="status"
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 max-w-[90vw] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel"
className="fixed bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-6 left-1/2 -translate-x-1/2 z-50 w-[calc(100vw-1.5rem)] sm:w-auto sm:max-w-[90vw] flex flex-col sm:block pl-4 pr-1 pt-3 pb-1 sm:px-3 sm:py-2 rounded-card text-sm sm:text-xs text-main bg-panel-elevated border border-border shadow-panel"
>
<span>{notice}</span>
{noticeActions.map((a) => (
<button
key={a.label}
type="button"
onClick={() => {
setNotice(null);
setNoticeActions([]);
a.run();
}}
className="ml-3 font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0"
>
{a.label}
</button>
))}
{/* Phones: text on top (two lines at most), buttons below; desktop: one line */}
<span className="max-sm:pr-3 max-sm:line-clamp-2">{notice}</span>
{noticeActions.length > 0 && (
<span className="flex justify-end sm:inline">
{noticeActions.map((a) => (
<button
key={a.label}
type="button"
onClick={() => {
setNotice(null);
setNoticeActions([]);
a.run();
}}
className="h-11 px-3 sm:h-auto sm:px-0 sm:ml-3 font-semibold text-accent hover:underline"
>
{a.label}
</button>
))}
</span>
)}
</div>
)}
</div>
@@ -42,6 +42,7 @@ import { mediaContent, parseMediaContent } from '../../lib/media';
import { renderInlineMarkdown } from '../../lib/markdown';
import { api } from '../../lib/api';
import { safeHref } from '../../lib/safeUrl';
import { isNoteLinkOnly } from '../../lib/wikiLink';
export interface SlashCommand {
type: BlockType | 'link' | 'dictation' | 'extract';
@@ -459,7 +460,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
// /extract only when note has content and is at start/end
if (c.type === 'extract') {
return !isRestEmpty && isAtStartOrEnd && !!onExtractBlock;
return !isRestEmpty && isAtStartOrEnd && !!onExtractBlock && !isNoteLinkOnly(restOfBlock);
}
// /h1, /h2, /h3, /todo, /callout, /text (type 'bullet') at start or at end
@@ -1579,7 +1580,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
className={`absolute right-1 sm:right-2 top-0.5 sm:top-1 z-20 bg-panel/90 backdrop-blur-sm border border-border shadow-xs rounded-pill px-1 sm:px-1.5 py-0.5 flex items-center gap-0.5 sm:gap-1 transition-opacity ${
activeFocus
? 'opacity-100 pointer-events-auto'
: 'opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-events-none group-hover:pointer-events-auto focus-within:pointer-events-auto'
: // Hover only with a real pointer: a tap on a phone would make the invisible buttons clickable
'opacity-0 [@media(hover:hover)]:group-hover:opacity-100 focus-within:opacity-100 pointer-events-none [@media(hover:hover)]:group-hover:pointer-events-auto focus-within:pointer-events-auto'
}`}
>
{/* Convert back to bullet for headings, callouts, dividers, code and numbered items */}
@@ -1702,7 +1704,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
)}
{/* Branch into its own note */}
{canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && (
{canEdit && onExtractBlock && localContent.trim() !== '' && !isNoteLinkOnly(localContent) && !mediaPicker && (
<button
type="button"
onClick={() => onExtractBlock(block.id)}
+9 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { buildBlockLink, parseWikiLink, wikiLinkText } from '../wikiLink';
import { buildBlockLink, isNoteLinkOnly, parseWikiLink, wikiLinkText } from '../wikiLink';
describe('wiki links', () => {
it('parses plain, labelled, section and bullet links', () => {
@@ -18,4 +18,12 @@ describe('wiki links', () => {
expect(parsed.blockId).toBe('blk_x');
expect(wikiLinkText(parsed)).toBe('Homelab neu › Backups liefen bisher nur manuell – das…');
});
it('recognises a bullet that is only a link to a note', () => {
expect(isNoteLinkOnly('[[Konzept: Garten]]')).toBe(true);
expect(isNoteLinkOnly('[[Konzept: Garten]] #draußen #idee')).toBe(true);
expect(isNoteLinkOnly('Siehe [[Konzept: Garten]]')).toBe(false);
expect(isNoteLinkOnly('[[Konzept: Garten]] und mehr')).toBe(false);
expect(isNoteLinkOnly('Garten')).toBe(false);
});
});
+5
View File
@@ -31,3 +31,8 @@ export function buildBlockLink(title: string, blockId: string, blockContent: str
const label = excerpt ? `${clean(title)} › ${excerpt}` : clean(title);
return `[[${clean(title)}#^${blockId}|${label}]]`;
}
/** A bullet that is only a link to a note (plus hashtags), as left behind when a branch became a note. */
export function isNoteLinkOnly(content: string): boolean {
return /^\s*\[\[[^\]]+\]\](\s+#[\p{L}\p{N}_-]+)*\s*$/u.test(content);
}
+1 -1
View File
@@ -29,7 +29,7 @@ func restructureStatus(err error) int {
return http.StatusConflict
case errors.Is(err, storage.ErrBlockNotFound), errors.Is(err, storage.ErrDocNotFound):
return http.StatusNotFound
case errors.Is(err, storage.ErrMergeSelf), errors.Is(err, storage.ErrDocInTrash):
case errors.Is(err, storage.ErrMergeSelf), errors.Is(err, storage.ErrDocInTrash), errors.Is(err, storage.ErrAlreadyNote):
return http.StatusBadRequest
case errors.Is(err, storage.ErrMergeNotOwner):
return http.StatusForbidden
+17
View File
@@ -84,6 +84,23 @@ func TestExtractBranchIntoOwnNote(t *testing.T) {
}
}
func TestExtractRejectsBulletThatIsOnlyANoteLink(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
claims, _ := auth.ValidateToken(testJWTSecret, token)
src, _ := store.CreateDocument(claims.UserID, "", "Projekt", "", "", []domain.DocumentBlock{
{ID: "blk_l", Type: "bullet", Content: "[[Konzept: Garten]] #draußen", Level: 0, Position: 1},
{ID: "blk_m", Type: "bullet", Content: "Siehe [[Konzept: Garten]] für Details", Level: 0, Position: 2},
}, nil, false)
if rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", token, `{"blockId":"blk_l"}`, ""); rec.Code != http.StatusBadRequest {
t.Fatalf("link-only bullet: want 400, got %d %s", rec.Code, rec.Body.String())
}
// A link inside other text is ordinary content
if rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", token, `{"blockId":"blk_m"}`, ""); rec.Code != http.StatusCreated {
t.Fatalf("bullet with text and link: want 201, got %d %s", rec.Code, rec.Body.String())
}
}
func TestExtractInSharedNoteKeepsAccess(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
+6
View File
@@ -20,6 +20,7 @@ var (
ErrBlockNotFound = errors.New("Punkt nicht gefunden")
ErrBranchLocked = errors.New("Jemand anderes bearbeitet gerade einen Punkt in diesem Ast. Bitte kurz warten.")
ErrMergeSelf = errors.New("Eine Notiz kann nicht mit sich selbst zusammengeführt werden")
ErrAlreadyNote = errors.New("Dieser Punkt verweist schon auf eine eigene Notiz")
ErrMergeNotOwner = errors.New("Zusammenführen geht nur mit eigenen Notizen als Quelle")
)
@@ -37,6 +38,8 @@ func (s *Store) readCollabDoc(docID string) (*collab.Doc, error) {
var (
titlePrefixRe = regexp.MustCompile(`^(\s*(-|\*|#{1,6}|\[[ xX]?\])\s+)+`)
titleTagRe = regexp.MustCompile(`(^|\s)#[\p{L}0-9_-]+`)
// A bullet that is only a link to a note (with hashtags), as left behind by ExtractBranch
noteLinkOnlyRe = regexp.MustCompile(`^\s*\[\[[^\]]+\]\](\s+#[\p{L}0-9_-]+)*\s*$`)
)
// branchTitle makes a note title from a bullet's text (without hashtags) and returns the hashtags,
@@ -116,6 +119,9 @@ func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, lea
return nil, "", ErrBlockNotFound
}
root := flat[start]
if noteLinkOnlyRe.MatchString(root.Content) {
return nil, "", ErrAlreadyNote
}
var children []domain.DocumentBlock
ids := []string{root.ID}
for i := start + 1; i < len(flat) && flat[i].Level > root.Level; i++ {