From f82ab2fd32fde60fd597f10be3a0d4e20cea2039 Mon Sep 17 00:00:00 2001 From: Sandro Date: Wed, 28 Oct 2026 16:50:51 +0100 Subject: [PATCH 1/8] fix: mobile layout, task clicks, list tab corners, hashtags in URLs Mobile - Plus button in triage did nothing: the stored action returned the capture function instead of calling it - Workspace: tabs share the full width; save status sits as an icon next to the note title instead of wrapping into its own row; "Neue Notiz" / "Neuer Ordner" only in the top menu (and +), not repeated in the list - Outliner fields measured while hidden stayed one line high (cut-off callout); they are measured again when their width changes - Task dialog: scrolls in a div instead of a fieldset, height in dvh, notes editor grows with its text, so long notes are fully reachable Desktop - Delete button of the selected task/shopping list sits inside the tab as its own rounded button (matches rounded themes like Soft) Both - Tasks sometimes did not open: every reload after an action replaced the list with a loading placeholder, swallowing clicks; reloads are now silent after the first load of a list, stale responses are dropped - Hashtags need whitespace or "(" before "#", so URL anchors are no longer tags (server, browser); one-time cleanup of stored anchor tags Co-Authored-By: Claude Opus 5.5 --- Doku/modules/workspace.md | 1 + frontend/src/App.tsx | 2 +- .../src/features/lists/SharedListsView.tsx | 15 ++-- .../src/features/triage/TaskDetailModal.tsx | 19 +++-- frontend/src/features/triage/TasksView.tsx | 30 +++++--- .../src/features/workspace/DocumentEditor.tsx | 38 ++++++++-- .../src/features/workspace/OutlinerBlock.tsx | 21 +++++- .../src/features/workspace/WorkspaceView.tsx | 26 ++++--- frontend/src/lib/__tests__/hashtags.test.ts | 13 ++++ frontend/src/lib/api.ts | 3 +- frontend/src/lib/hashtags.ts | 9 +++ internal/domain/models.go | 28 +++++++- internal/domain/tags_test.go | 34 +++++++++ internal/storage/docsync_test.go | 19 +++++ internal/storage/sqlite.go | 1 + internal/storage/tags_migration.go | 71 +++++++++++++++++++ 16 files changed, 283 insertions(+), 47 deletions(-) create mode 100644 frontend/src/lib/__tests__/hashtags.test.ts create mode 100644 frontend/src/lib/hashtags.ts create mode 100644 internal/domain/tags_test.go create mode 100644 internal/storage/tags_migration.go diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index c19a5c2..6127a75 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -6,6 +6,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts - Eine Notiz ist ein Datensatz in `documents` plus ein Baum von Blöcken in `blocks` (`parent_id`, Sortierschlüssel `order_key`). Jede Änderung läuft über `internal/collab` und wird im Log `doc_ops` festgehalten. Ebene und Position für die Oberfläche berechnet der Server beim Lesen. Das Log und gelöschte Blöcke werden regelmäßig verdichtet ([database.md](../database.md#verdichtung-des-notiz-logs)). Alles liegt in SQLite; es gibt keine Dateien auf der Platte. - Bei jeder Änderung wird `documents.markdown_cache` neu erzeugt; `block_tags` wird aus `#Hashtags` der Blöcke neu aufgebaut. +- **Hashtag-Regel:** `#wort` zählt nur am Textanfang oder nach Leerzeichen bzw. `(`. Anker in Links (`example.com/blog#ueberschrift`) und Wörter wie `C#` sind keine Tags. Gleiche Regel im Server (`domain.ExtractTags`) und im Browser (`lib/hashtags.ts`). Beim ersten Start nach der Umstellung entfernt `migrateAnchorTags` einmalig gespeicherte Tags, die nur aus Ankern stammten. - Der Export als `.md` erfolgt im Browser (Button im Editor, Blob-Download). - Blocktypen: Überschriften `h1`–`h3`, `paragraph`/`text`, `bullet`, `todo`, `code`, `callout`, `divider`, `table`, `progress`. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e503801..40ba5c5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -226,7 +226,7 @@ export const App: React.FC = () => { useEffect(() => { closeMenu(); if (activeTab === 'triage') { - setPrimaryAction(() => openMobileTriageCapture); + setPrimaryAction(openMobileTriageCapture); setGroups([ { id: 'triage-actions', diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index b06384c..0ae8948 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -825,14 +825,15 @@ export const SharedListsView: React.FC = ({ onRefreshGloba const openCount = deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length; const isSelected = activeList?.id === lst.id; return ( -
+
1 ? 'pr-1' : ''}`} + > diff --git a/frontend/src/features/triage/TaskDetailModal.tsx b/frontend/src/features/triage/TaskDetailModal.tsx index a1d7dec..93720ef 100644 --- a/frontend/src/features/triage/TaskDetailModal.tsx +++ b/frontend/src/features/triage/TaskDetailModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { X, Check, @@ -71,6 +71,12 @@ export const TaskDetailModal: React.FC = ({ const [isEditingNotes, setIsEditingNotes] = useState(false); const [notesDraft, setNotesDraft] = useState(task.notes || ''); + // The notes field grows with its text, so long notes scroll with the dialog instead of inside a small box + const growNotes = useCallback((el: HTMLTextAreaElement | null) => { + if (!el) return; + el.style.height = 'auto'; + el.style.height = `${el.scrollHeight + 2}px`; + }, []); const [newSubtaskTitle, setNewSubtaskTitle] = useState(''); const [newTagDraft, setNewTagDraft] = useState(''); @@ -172,7 +178,7 @@ export const TaskDetailModal: React.FC = ({ return (
e.stopPropagation()} > {/* Top Header */} @@ -272,7 +278,9 @@ export const TaskDetailModal: React.FC = ({
{/* Scrollable Body (all controls disabled for read-only shares) */} -
+ {/* The scroll container is a div: a scrolling
is unreliable in mobile browsers */} +
+
{/* Task Title */}
{isEditingTitle ? ( @@ -672,7 +680,9 @@ export const TaskDetailModal: React.FC = ({ placeholder="Detailnotizen eingeben… (Markdown unterstützt)" rows={6} autoFocus - className="w-full input-field p-2.5 text-xs font-mono leading-relaxed resize-y min-h-[100px]" + ref={growNotes} + onInput={(e) => growNotes(e.currentTarget)} + className="w-full input-field p-2.5 text-xs font-mono leading-relaxed resize-none min-h-[100px] overflow-hidden" />
+
{/* Modal Footer */}
diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 2ee010e..e414855 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -134,16 +134,25 @@ export const TasksView: React.FC = ({ const [groupBy, setGroupBy] = useState<'none' | 'tag' | 'status' | 'priority' | 'due'>('none'); const [sortBy, setSortBy] = useState<'custom' | 'created' | 'due' | 'priority' | 'alpha'>('custom'); + // Reloads happen after almost every action and on realtime events. Only the first load of a list shows + // the loading placeholder: replacing the rendered list on every reload swallowed clicks and taps that + // happened to fall into the reload (press and release then hit different elements). + const loadedListRef = useRef(null); + const loadSeqRef = useRef(0); const loadData = async () => { - setLoading(true); + const seq = ++loadSeqRef.current; + const listId = selectedListId; + if (loadedListRef.current !== listId) setLoading(true); try { const [allTasks, allLists, allTeams, allCollabs, allDiscarded] = await Promise.all([ - api.getTasks(selectedListId === 'all' ? undefined : selectedListId).catch(() => []), + api.getTasks(listId === 'all' ? undefined : listId).catch(() => []), api.getTaskLists().catch(() => []), api.getUserTeams().catch(() => []), api.getCollaborators().catch(() => []), api.getThoughts('discarded').catch(() => []), ]); + if (seq !== loadSeqRef.current) return; // a newer reload is under way + loadedListRef.current = listId; setTasks(Array.isArray(allTasks) ? allTasks : []); setTaskLists(Array.isArray(allLists) ? allLists : []); setTeams(Array.isArray(allTeams) ? allTeams : []); @@ -152,7 +161,7 @@ export const TasksView: React.FC = ({ } catch { // Prevent unhandled promise rejection from crashing view } finally { - setLoading(false); + if (seq === loadSeqRef.current) setLoading(false); } }; @@ -1333,14 +1342,15 @@ export const TasksView: React.FC = ({ {safeTaskLists.map((tl) => ( -
+
diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 71fc774..539b585 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -14,11 +14,15 @@ import { Sparkles, Plus, Search, + Check, + CloudOff, + AlertTriangle, } from 'lucide-react'; import { Document, DocumentBlock, User } from '../../lib/types'; import { api } from '../../lib/api'; import { DocSyncEngine, EngineState } from '../../lib/docSync/engine'; import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree'; +import { extractHashtags } from '../../lib/hashtags'; export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error'; @@ -314,12 +318,7 @@ export const DocumentEditor: React.FC = ({ const extractedTags = useMemo(() => { const tagSet = new Set(); for (const b of blocks) { - const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g); - if (matches) { - for (const m of matches) { - tagSet.add(m.slice(1).toLowerCase()); - } - } + for (const tag of extractHashtags(b.content)) tagSet.add(tag); } return Array.from(tagSet); }, [blocks]); @@ -755,6 +754,33 @@ export const DocumentEditor: React.FC = ({
)} + {/* Save status on phones (the desktop shows it above the workspace) */} + {engineState && ( + + {engineState.status === 'saving' ? ( + + ) : engineState.status === 'offline' ? ( + + ) : engineState.status === 'error' ? ( + + ) : ( + + )} + + )} + {/* Read-only indicator */} {!canEdit && ( diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 5ad4e48..fb98dae 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -288,8 +288,25 @@ export const OutlinerBlock: React.FC = ({ useEffect(() => { window.addEventListener('resize', adjustHeight); - return () => window.removeEventListener('resize', adjustHeight); - }, [adjustHeight]); + // The field can be measured while hidden (phone layout shows the editor only after selecting a note); + // measure again whenever its width changes + const el = textareaRef.current; + let lastWidth = el?.clientWidth ?? 0; + const observer = + el && typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(() => { + if (el.clientWidth !== lastWidth) { + lastWidth = el.clientWidth; + adjustHeight(); + } + }) + : null; + if (el) observer?.observe(el); + return () => { + window.removeEventListener('resize', adjustHeight); + observer?.disconnect(); + }; + }, [adjustHeight, block.type]); const filteredSlashCommands = useMemo(() => { if (slashSearch === null) return []; diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 374cc4e..ad4ff2e 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -52,6 +52,7 @@ import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { sendDocOps } from '../../lib/docSync/oneoff'; import { useMobileActions } from '../../context/MobileActionContext'; import { DocumentEditor, DocSaveStatus } from './DocumentEditor'; +import { extractHashtags } from '../../lib/hashtags'; interface WorkspaceViewProps { currentUser?: User | null; @@ -289,12 +290,8 @@ export const WorkspaceView: React.FC = ({ const tagMap = new Map(); for (const d of docs) { for (const b of d.blocks || []) { - const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g); - if (matches) { - for (const m of matches) { - const clean = m.slice(1).toLowerCase(); - tagMap.set(clean, (tagMap.get(clean) || 0) + 1); - } + for (const clean of extractHashtags(b.content)) { + tagMap.set(clean, (tagMap.get(clean) || 0) + 1); } } } @@ -1117,8 +1114,8 @@ export const WorkspaceView: React.FC = ({
{/* Top Header: Workspace Tabs & Actions */}
- {/* Workspace selector tabs & left sidebar toggle */} -
+ {/* Workspace selector tabs & left sidebar toggle; on phones the three tabs share the full width */} +
- {/* Right Header Section: Save Status & Live Collaboration Button */} -
+ {/* Right Header Section: Save Status (on phones it sits compactly in the note's own header) */} +
{subView === 'editor' && selectedDocId ? ( /* Save Status Pill */
= ({

Notizen & Outliner

-
+ {/* On phones these are in the menu at the top (and the + button), so not repeated here */} +