fix: mobile layout, task clicks, list tab corners, hashtags in URLs
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m25s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m25s
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
41b02b9564
commit
f82ab2fd32
16 files changed
+283
-47
No files matched your search
@@ -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.
|
- 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.
|
- 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).
|
- 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`.
|
- Blocktypen: Überschriften `h1`–`h3`, `paragraph`/`text`, `bullet`, `todo`, `code`, `callout`, `divider`, `table`, `progress`.
|
||||||
|
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ export const App: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
closeMenu();
|
closeMenu();
|
||||||
if (activeTab === 'triage') {
|
if (activeTab === 'triage') {
|
||||||
setPrimaryAction(() => openMobileTriageCapture);
|
setPrimaryAction(openMobileTriageCapture);
|
||||||
setGroups([
|
setGroups([
|
||||||
{
|
{
|
||||||
id: 'triage-actions',
|
id: 'triage-actions',
|
||||||
|
|||||||
@@ -825,14 +825,15 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
const openCount = deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length;
|
const openCount = deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length;
|
||||||
const isSelected = activeList?.id === lst.id;
|
const isSelected = activeList?.id === lst.id;
|
||||||
return (
|
return (
|
||||||
<div key={lst.id} className="relative group flex items-center">
|
<div
|
||||||
|
key={lst.id}
|
||||||
|
className={`relative group h-8 flex items-center border rounded-btn transition-colors ${
|
||||||
|
isSelected ? 'bg-panel border-accent text-main' : 'bg-canvas border-border text-muted hover:text-main'
|
||||||
|
} ${isSelected && lists.length > 1 ? 'pr-1' : ''}`}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveListId(lst.id)}
|
onClick={() => setActiveListId(lst.id)}
|
||||||
className={`h-8 px-3 text-xs font-medium border flex items-center gap-1.5 transition-colors ${
|
className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn"
|
||||||
isSelected
|
|
||||||
? 'bg-panel border-accent text-main'
|
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
|
||||||
} ${isSelected && lists.length > 1 ? 'rounded-l-btn rounded-r-none border-r-0' : 'rounded-btn'}`}
|
|
||||||
>
|
>
|
||||||
<span>{lst.icon}</span>
|
<span>{lst.icon}</span>
|
||||||
<span>{lst.title}</span>
|
<span>{lst.title}</span>
|
||||||
@@ -844,7 +845,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteList(lst.id)}
|
onClick={() => handleDeleteList(lst.id)}
|
||||||
title="Liste löschen"
|
title="Liste löschen"
|
||||||
className="h-8 px-2 bg-panel border-y border-r border-accent text-faint hover:text-status-err hover:bg-status-err/10 rounded-r-btn flex items-center justify-center transition-colors cursor-pointer"
|
className="h-6 w-6 text-faint hover:text-status-err hover:bg-status-err/10 rounded-btn flex items-center justify-center transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
Check,
|
Check,
|
||||||
@@ -71,6 +71,12 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
|
|
||||||
const [isEditingNotes, setIsEditingNotes] = useState(false);
|
const [isEditingNotes, setIsEditingNotes] = useState(false);
|
||||||
const [notesDraft, setNotesDraft] = useState(task.notes || '');
|
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 [newSubtaskTitle, setNewSubtaskTitle] = useState('');
|
||||||
const [newTagDraft, setNewTagDraft] = useState('');
|
const [newTagDraft, setNewTagDraft] = useState('');
|
||||||
@@ -172,7 +178,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4 overflow-y-auto">
|
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4 overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-2xl panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[92vh] overflow-hidden"
|
className="w-full max-w-2xl panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[92vh] max-h-[92dvh] overflow-hidden"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Top Header */}
|
{/* Top Header */}
|
||||||
@@ -272,7 +278,9 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Body (all controls disabled for read-only shares) */}
|
{/* Scrollable Body (all controls disabled for read-only shares) */}
|
||||||
<fieldset disabled={readOnly} className="p-4 overflow-y-auto space-y-4 flex-1 min-w-0 border-0 m-0">
|
{/* The scroll container is a div: a scrolling <fieldset> is unreliable in mobile browsers */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain [-webkit-overflow-scrolling:touch]">
|
||||||
|
<fieldset disabled={readOnly} className="p-4 space-y-4 min-w-0 border-0 m-0">
|
||||||
{/* Task Title */}
|
{/* Task Title */}
|
||||||
<div>
|
<div>
|
||||||
{isEditingTitle ? (
|
{isEditingTitle ? (
|
||||||
@@ -672,7 +680,9 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
placeholder="Detailnotizen eingeben… (Markdown unterstützt)"
|
placeholder="Detailnotizen eingeben… (Markdown unterstützt)"
|
||||||
rows={6}
|
rows={6}
|
||||||
autoFocus
|
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"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end gap-1.5">
|
<div className="flex justify-end gap-1.5">
|
||||||
<button
|
<button
|
||||||
@@ -725,6 +735,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Modal Footer */}
|
{/* Modal Footer */}
|
||||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
|
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
|
||||||
|
|||||||
@@ -134,16 +134,25 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
const [groupBy, setGroupBy] = useState<'none' | 'tag' | 'status' | 'priority' | 'due'>('none');
|
const [groupBy, setGroupBy] = useState<'none' | 'tag' | 'status' | 'priority' | 'due'>('none');
|
||||||
const [sortBy, setSortBy] = useState<'custom' | 'created' | 'due' | 'priority' | 'alpha'>('custom');
|
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<string | null>(null);
|
||||||
|
const loadSeqRef = useRef(0);
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
const seq = ++loadSeqRef.current;
|
||||||
|
const listId = selectedListId;
|
||||||
|
if (loadedListRef.current !== listId) setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [allTasks, allLists, allTeams, allCollabs, allDiscarded] = await Promise.all([
|
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.getTaskLists().catch(() => []),
|
||||||
api.getUserTeams().catch(() => []),
|
api.getUserTeams().catch(() => []),
|
||||||
api.getCollaborators().catch(() => []),
|
api.getCollaborators().catch(() => []),
|
||||||
api.getThoughts('discarded').catch(() => []),
|
api.getThoughts('discarded').catch(() => []),
|
||||||
]);
|
]);
|
||||||
|
if (seq !== loadSeqRef.current) return; // a newer reload is under way
|
||||||
|
loadedListRef.current = listId;
|
||||||
setTasks(Array.isArray(allTasks) ? allTasks : []);
|
setTasks(Array.isArray(allTasks) ? allTasks : []);
|
||||||
setTaskLists(Array.isArray(allLists) ? allLists : []);
|
setTaskLists(Array.isArray(allLists) ? allLists : []);
|
||||||
setTeams(Array.isArray(allTeams) ? allTeams : []);
|
setTeams(Array.isArray(allTeams) ? allTeams : []);
|
||||||
@@ -152,7 +161,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
} catch {
|
} catch {
|
||||||
// Prevent unhandled promise rejection from crashing view
|
// Prevent unhandled promise rejection from crashing view
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (seq === loadSeqRef.current) setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1333,14 +1342,15 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{safeTaskLists.map((tl) => (
|
{safeTaskLists.map((tl) => (
|
||||||
<div key={tl.id} className="relative group flex items-center">
|
<div
|
||||||
|
key={tl.id}
|
||||||
|
className={`relative group h-8 flex items-center border rounded-btn transition-colors ${
|
||||||
|
selectedListId === tl.id ? 'bg-panel border-accent text-main pr-1' : 'bg-canvas border-border text-muted hover:text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedListId(tl.id)}
|
onClick={() => setSelectedListId(tl.id)}
|
||||||
className={`h-8 px-3 text-xs font-medium border flex items-center gap-1.5 transition-colors ${
|
className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn"
|
||||||
selectedListId === tl.id
|
|
||||||
? 'bg-panel border-accent text-main rounded-l-btn rounded-r-none border-r-0'
|
|
||||||
: 'bg-canvas border-border text-muted hover:text-main rounded-btn'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<span>{tl.icon}</span>
|
<span>{tl.icon}</span>
|
||||||
<span>{tl.title}</span>
|
<span>{tl.title}</span>
|
||||||
@@ -1377,7 +1387,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteTaskList(tl.id)}
|
onClick={() => handleDeleteTaskList(tl.id)}
|
||||||
title="Liste löschen"
|
title="Liste löschen"
|
||||||
className="h-8 px-2 bg-panel border-y border-r border-accent text-faint hover:text-status-err hover:bg-status-err/10 rounded-r-btn flex items-center justify-center transition-colors cursor-pointer"
|
className="h-6 w-6 text-faint hover:text-status-err hover:bg-status-err/10 rounded-btn flex items-center justify-center transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -14,11 +14,15 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
|
Check,
|
||||||
|
CloudOff,
|
||||||
|
AlertTriangle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Document, DocumentBlock, User } from '../../lib/types';
|
import { Document, DocumentBlock, User } from '../../lib/types';
|
||||||
import { api } from '../../lib/api';
|
import { api } from '../../lib/api';
|
||||||
import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
|
import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
|
||||||
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
|
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
|
||||||
|
import { extractHashtags } from '../../lib/hashtags';
|
||||||
|
|
||||||
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
||||||
|
|
||||||
@@ -314,12 +318,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
const extractedTags = useMemo(() => {
|
const extractedTags = useMemo(() => {
|
||||||
const tagSet = new Set<string>();
|
const tagSet = new Set<string>();
|
||||||
for (const b of blocks) {
|
for (const b of blocks) {
|
||||||
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
for (const tag of extractHashtags(b.content)) tagSet.add(tag);
|
||||||
if (matches) {
|
|
||||||
for (const m of matches) {
|
|
||||||
tagSet.add(m.slice(1).toLowerCase());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Array.from(tagSet);
|
return Array.from(tagSet);
|
||||||
}, [blocks]);
|
}, [blocks]);
|
||||||
@@ -755,6 +754,33 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Save status on phones (the desktop shows it above the workspace) */}
|
||||||
|
{engineState && (
|
||||||
|
<span
|
||||||
|
className="sm:hidden inline-flex items-center shrink-0"
|
||||||
|
title={
|
||||||
|
engineState.status === 'saving'
|
||||||
|
? 'Speichert…'
|
||||||
|
: engineState.status === 'offline'
|
||||||
|
? 'Offline – Änderungen sind lokal gesichert'
|
||||||
|
: engineState.status === 'error'
|
||||||
|
? engineState.errorMessage || 'Fehler beim Speichern'
|
||||||
|
: 'Gespeichert'
|
||||||
|
}
|
||||||
|
aria-label="Speicherstatus"
|
||||||
|
>
|
||||||
|
{engineState.status === 'saving' ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
|
||||||
|
) : engineState.status === 'offline' ? (
|
||||||
|
<CloudOff className="w-3.5 h-3.5 text-status-warn" />
|
||||||
|
) : engineState.status === 'error' ? (
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5 text-status-err" />
|
||||||
|
) : (
|
||||||
|
<Check className="w-3.5 h-3.5 text-status-ok" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Read-only indicator */}
|
{/* Read-only indicator */}
|
||||||
{!canEdit && (
|
{!canEdit && (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] font-medium bg-status-warn/10 text-status-warn border border-status-warn/30">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] font-medium bg-status-warn/10 text-status-warn border border-status-warn/30">
|
||||||
|
|||||||
@@ -288,8 +288,25 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.addEventListener('resize', adjustHeight);
|
window.addEventListener('resize', adjustHeight);
|
||||||
return () => window.removeEventListener('resize', adjustHeight);
|
// The field can be measured while hidden (phone layout shows the editor only after selecting a note);
|
||||||
}, [adjustHeight]);
|
// 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(() => {
|
const filteredSlashCommands = useMemo(() => {
|
||||||
if (slashSearch === null) return [];
|
if (slashSearch === null) return [];
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
|||||||
import { sendDocOps } from '../../lib/docSync/oneoff';
|
import { sendDocOps } from '../../lib/docSync/oneoff';
|
||||||
import { useMobileActions } from '../../context/MobileActionContext';
|
import { useMobileActions } from '../../context/MobileActionContext';
|
||||||
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
|
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
|
||||||
|
import { extractHashtags } from '../../lib/hashtags';
|
||||||
|
|
||||||
interface WorkspaceViewProps {
|
interface WorkspaceViewProps {
|
||||||
currentUser?: User | null;
|
currentUser?: User | null;
|
||||||
@@ -289,12 +290,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const tagMap = new Map<string, number>();
|
const tagMap = new Map<string, number>();
|
||||||
for (const d of docs) {
|
for (const d of docs) {
|
||||||
for (const b of d.blocks || []) {
|
for (const b of d.blocks || []) {
|
||||||
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
for (const clean of extractHashtags(b.content)) {
|
||||||
if (matches) {
|
tagMap.set(clean, (tagMap.get(clean) || 0) + 1);
|
||||||
for (const m of matches) {
|
|
||||||
const clean = m.slice(1).toLowerCase();
|
|
||||||
tagMap.set(clean, (tagMap.get(clean) || 0) + 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1117,8 +1114,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<div className="w-full space-y-3">
|
<div className="w-full space-y-3">
|
||||||
{/* Top Header: Workspace Tabs & Actions */}
|
{/* Top Header: Workspace Tabs & Actions */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
|
||||||
{/* Workspace selector tabs & left sidebar toggle */}
|
{/* Workspace selector tabs & left sidebar toggle; on phones the three tabs share the full width */}
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
<div className="grid grid-cols-3 w-full gap-1 sm:flex sm:flex-wrap sm:items-center sm:w-auto">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={subView !== 'editor'}
|
disabled={subView !== 'editor'}
|
||||||
@@ -1155,7 +1152,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSubView('editor')}
|
onClick={() => setSubView('editor')}
|
||||||
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors flex items-center gap-1.5 cursor-pointer ${
|
className={`h-8 px-2 sm:px-3 text-xs font-medium border rounded-btn transition-colors flex items-center justify-center gap-1.5 cursor-pointer min-w-0 ${
|
||||||
subView === 'editor'
|
subView === 'editor'
|
||||||
? 'bg-panel border-accent text-main'
|
? 'bg-panel border-accent text-main'
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
: 'bg-canvas border-border text-muted hover:text-main'
|
||||||
@@ -1172,7 +1169,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
setSubView('todos');
|
setSubView('todos');
|
||||||
setMobileShowDetail(true);
|
setMobileShowDetail(true);
|
||||||
}}
|
}}
|
||||||
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors flex items-center gap-1.5 cursor-pointer ${
|
className={`h-8 px-2 sm:px-3 text-xs font-medium border rounded-btn transition-colors flex items-center justify-center gap-1.5 cursor-pointer min-w-0 ${
|
||||||
subView === 'todos'
|
subView === 'todos'
|
||||||
? 'bg-panel border-accent text-main'
|
? 'bg-panel border-accent text-main'
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
: 'bg-canvas border-border text-muted hover:text-main'
|
||||||
@@ -1189,7 +1186,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
setSubView('tags');
|
setSubView('tags');
|
||||||
setMobileShowDetail(true);
|
setMobileShowDetail(true);
|
||||||
}}
|
}}
|
||||||
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors flex items-center gap-1.5 cursor-pointer ${
|
className={`h-8 px-2 sm:px-3 text-xs font-medium border rounded-btn transition-colors flex items-center justify-center gap-1.5 cursor-pointer min-w-0 ${
|
||||||
subView === 'tags' || subView === 'tag_detail'
|
subView === 'tags' || subView === 'tag_detail'
|
||||||
? 'bg-panel border-accent text-main'
|
? 'bg-panel border-accent text-main'
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
: 'bg-canvas border-border text-muted hover:text-main'
|
||||||
@@ -1201,8 +1198,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Header Section: Save Status & Live Collaboration Button */}
|
{/* Right Header Section: Save Status (on phones it sits compactly in the note's own header) */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="hidden sm:flex items-center gap-2">
|
||||||
{subView === 'editor' && selectedDocId ? (
|
{subView === 'editor' && selectedDocId ? (
|
||||||
/* Save Status Pill */
|
/* Save Status Pill */
|
||||||
<div
|
<div
|
||||||
@@ -1272,7 +1269,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<h2 className="text-xs font-bold text-main uppercase tracking-wider">
|
<h2 className="text-xs font-bold text-main uppercase tracking-wider">
|
||||||
Notizen & Outliner
|
Notizen & Outliner
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-1">
|
{/* On phones these are in the menu at the top (and the + button), so not repeated here */}
|
||||||
|
<div className="hidden sm:flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => handleOpenNewFolder('', e)}
|
onClick={(e) => handleOpenNewFolder('', e)}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { extractHashtags } from '../hashtags';
|
||||||
|
|
||||||
|
describe('extractHashtags', () => {
|
||||||
|
it('finds tags at the start, after spaces and after "("', () => {
|
||||||
|
expect(extractHashtags('#start mitten #zwei (#drei)')).toEqual(['start', 'zwei', 'drei']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores URL anchors and words like C#', () => {
|
||||||
|
expect(extractHashtags('example.com/blog#ueberschrift')).toEqual([]);
|
||||||
|
expect(extractHashtags('https://x.de/#anker und #echt, C# nicht')).toEqual(['echt']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
UserInvite,
|
UserInvite,
|
||||||
WSMessage,
|
WSMessage,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { extractHashtags } from './hashtags';
|
||||||
|
|
||||||
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
|
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
|
||||||
const OFFLINE_QUEUE_KEY = 'mindsift_offline_capture_queue';
|
const OFFLINE_QUEUE_KEY = 'mindsift_offline_capture_queue';
|
||||||
@@ -254,7 +255,7 @@ export const api = {
|
|||||||
id: `th_offline_${Date.now()}`,
|
id: `th_offline_${Date.now()}`,
|
||||||
userId: 'offline',
|
userId: 'offline',
|
||||||
content,
|
content,
|
||||||
tags: (content.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g) || []).map((t) => t.slice(1).toLowerCase()),
|
tags: Array.from(new Set(extractHashtags(content))),
|
||||||
priority,
|
priority,
|
||||||
source,
|
source,
|
||||||
status: 'inbox',
|
status: 'inbox',
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// A hashtag starts the text or follows whitespace or "(". Fragments in links (example.com/blog#ueberschrift)
|
||||||
|
// and words like "C#" are not tags. Same rule as the server (domain.ExtractTags).
|
||||||
|
const HASHTAG = /(?:^|[\s(])#([a-zA-Z0-9äöüÄÖÜß_-]+)/g;
|
||||||
|
|
||||||
|
/** All hashtags in a text, lower case, in order of appearance (with duplicates). */
|
||||||
|
export function extractHashtags(text: string | undefined | null): string[] {
|
||||||
|
if (!text) return [];
|
||||||
|
return Array.from(text.matchAll(HASHTAG), (m) => m[1].toLowerCase());
|
||||||
|
}
|
||||||
@@ -21,7 +21,31 @@ func NowISO() string {
|
|||||||
return time.Now().UTC().Format(time.RFC3339)
|
return time.Now().UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
|
||||||
var hashtagRegex = regexp.MustCompile(`#([a-zA-Z0-9äöüÄÖÜß_-]+)`)
|
// A hashtag starts the text or follows whitespace or "(". Fragments in links (example.com/blog#ueberschrift)
|
||||||
|
// and words like "C#" are not tags. The browser uses the same rule (lib/markdown.tsx, lib/api.ts).
|
||||||
|
var hashtagRegex = regexp.MustCompile(`(?:^|[\s(])#([a-zA-Z0-9äöüÄÖÜß_-]+)`)
|
||||||
|
|
||||||
|
// legacyHashtagRegex is the rule before anchors were excluded; only used to clean up stored tags.
|
||||||
|
var legacyHashtagRegex = regexp.MustCompile(`#([a-zA-Z0-9äöüÄÖÜß_-]+)`)
|
||||||
|
|
||||||
|
// StaleAnchorTags returns tags the old rule found in content that the current rule does not, i.e. tags that
|
||||||
|
// only came from URL fragments and similar.
|
||||||
|
func StaleAnchorTags(content string) []string {
|
||||||
|
current := map[string]bool{}
|
||||||
|
for _, t := range ExtractTags(content) {
|
||||||
|
current[t] = true
|
||||||
|
}
|
||||||
|
var stale []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, m := range legacyHashtagRegex.FindAllStringSubmatch(content, -1) {
|
||||||
|
t := strings.ToLower(m[1])
|
||||||
|
if !current[t] && !seen[t] {
|
||||||
|
seen[t] = true
|
||||||
|
stale = append(stale, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stale
|
||||||
|
}
|
||||||
|
|
||||||
// ExtractTags extracts #hashtags from content
|
// ExtractTags extracts #hashtags from content
|
||||||
func ExtractTags(content string) []string {
|
func ExtractTags(content string) []string {
|
||||||
@@ -78,7 +102,7 @@ func FormatGermanDateTime(isoStr string) string {
|
|||||||
time.May: "Mai",
|
time.May: "Mai",
|
||||||
time.June: "Juni",
|
time.June: "Juni",
|
||||||
time.July: "Juli",
|
time.July: "Juli",
|
||||||
time.August: "Aug.",
|
time.August: "Aug.",
|
||||||
time.September: "Sept.",
|
time.September: "Sept.",
|
||||||
time.October: "Okt.",
|
time.October: "Okt.",
|
||||||
time.November: "Nov.",
|
time.November: "Nov.",
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtractTagsIgnoresURLAnchors(t *testing.T) {
|
||||||
|
cases := map[string][]string{
|
||||||
|
"#start mitten #zwei": {"start", "zwei"},
|
||||||
|
"siehe example.com/blog#ueberschrift": {},
|
||||||
|
"https://example.com/blog#ueberschrift und #echt": {"echt"},
|
||||||
|
"[Link](https://x.de/#anker) #tag": {"tag"},
|
||||||
|
"(#inKlammern) C# ist kein Tag": {"inklammern"},
|
||||||
|
"Zeile eins\n#zeilenanfang": {"zeilenanfang"},
|
||||||
|
"#Doppelt #doppelt": {"doppelt"},
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
got := ExtractTags(in)
|
||||||
|
if len(got) == 0 && len(want) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("ExtractTags(%q) = %v, want %v", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaleAnchorTags(t *testing.T) {
|
||||||
|
got := StaleAnchorTags("example.com/a#teil und #teil sowie x.de/#nur")
|
||||||
|
if !reflect.DeepEqual(got, []string{"nur"}) {
|
||||||
|
t.Fatalf("only tags that exist solely as anchors are stale, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -406,3 +406,22 @@ func TestDemoAccountsGetSampleDataAndShares(t *testing.T) {
|
|||||||
t.Fatalf("restart seeded again: %d notes", docs)
|
t.Fatalf("restart seeded again: %d notes", docs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnchorTagMigrationKeepsRealTags(t *testing.T) {
|
||||||
|
s, u := newSyncTestStore(t)
|
||||||
|
th, err := s.CreateThought(u.ID, "Lesen: example.com/blog#ueberschrift #lesen", "normal", "manual", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(th.Tags) != 1 || th.Tags[0] != "lesen" {
|
||||||
|
t.Fatalf("new thoughts must not get anchor tags: %v", th.Tags)
|
||||||
|
}
|
||||||
|
// State before the rule change: anchor stored as tag, plus a tag added by hand
|
||||||
|
s.db.Exec(`UPDATE thoughts SET tags = '["ueberschrift","lesen","manuell"]' WHERE id = ?`, th.ID)
|
||||||
|
s.db.Exec(`DELETE FROM system_settings WHERE key = ?`, tagsAnchorMigrationKey)
|
||||||
|
s.migrateAnchorTags()
|
||||||
|
got, _ := s.GetThoughtByID(u.ID, th.ID)
|
||||||
|
if len(got.Tags) != 2 || got.Tags[0] != "lesen" || got.Tags[1] != "manuell" {
|
||||||
|
t.Fatalf("only the anchor tag must go: %v", got.Tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -470,6 +470,7 @@ func (s *Store) migrate() error {
|
|||||||
if tagCount == 0 {
|
if tagCount == 0 {
|
||||||
_ = s.reindexAllDocBlockTags()
|
_ = s.reindexAllDocBlockTags()
|
||||||
}
|
}
|
||||||
|
s.migrateAnchorTags()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/sandrodach/mindsift/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tagsAnchorMigrationKey = "migration_tags_without_anchors"
|
||||||
|
|
||||||
|
// migrateAnchorTags removes tags that only came from URL fragments (example.com/blog#ueberschrift) since the
|
||||||
|
// hashtag rule now requires whitespace or "(" before "#". Runs once. Only such tags are removed from
|
||||||
|
// thoughts and notes, so tags users added by hand stay; block_tags is rebuilt from the blocks.
|
||||||
|
func (s *Store) migrateAnchorTags() {
|
||||||
|
var done string
|
||||||
|
if s.db.QueryRow(`SELECT value FROM system_settings WHERE key = ?`, tagsAnchorMigrationKey).Scan(&done) == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cleaned := 0
|
||||||
|
for _, table := range []struct{ name, text string }{{"thoughts", "content"}, {"documents", "content"}} {
|
||||||
|
rows, err := s.db.Query(`SELECT id, ` + table.text + `, tags FROM ` + table.name + ` WHERE ` + table.text + ` LIKE '%#%'`)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Bereinigung der Hashtags fehlgeschlagen", "table", table.name, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type row struct{ id, text, tags string }
|
||||||
|
var list []row
|
||||||
|
for rows.Next() {
|
||||||
|
var r row
|
||||||
|
if rows.Scan(&r.id, &r.text, &r.tags) == nil {
|
||||||
|
list = append(list, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
for _, r := range list {
|
||||||
|
stale := domain.StaleAnchorTags(r.text)
|
||||||
|
if len(stale) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
drop := map[string]bool{}
|
||||||
|
for _, t := range stale {
|
||||||
|
drop[t] = true
|
||||||
|
}
|
||||||
|
var tags []string
|
||||||
|
if json.Unmarshal([]byte(r.tags), &tags) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept := make([]string, 0, len(tags))
|
||||||
|
for _, t := range tags {
|
||||||
|
if !drop[t] {
|
||||||
|
kept = append(kept, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) != len(tags) {
|
||||||
|
out, _ := json.Marshal(kept)
|
||||||
|
if _, err := s.db.Exec(`UPDATE `+table.name+` SET tags = ? WHERE id = ?`, string(out), r.id); err == nil {
|
||||||
|
cleaned++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.reindexAllDocBlockTags(); err != nil {
|
||||||
|
slog.Error("Neuaufbau der Block-Hashtags fehlgeschlagen", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = s.db.Exec(`INSERT OR REPLACE INTO system_settings (key, value) VALUES (?, 'done')`, tagsAnchorMigrationKey)
|
||||||
|
if cleaned > 0 {
|
||||||
|
slog.Info("Hashtags aus URL-Ankern entfernt", "entries", cleaned)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user