Merge dev into main (v0.45.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 29s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 29s
This commit is contained in:
commit
3f96608992
27 files changed
+535
-64
No files matched your search
+12
-1
@@ -6,6 +6,16 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.45.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Notizen: Text lässt sich mit der Maus über mehrere Punkte hinweg markieren. Entf/Backspace löscht ihn wie in einem Texteditor (der Rest des ersten und des letzten Punkts wird ein Punkt, Punkte dazwischen samt Unterpunkten verschwinden), ein getipptes Zeichen ersetzt ihn, Strg+C kopiert, Strg+X schneidet aus. Strg+Z holt alles in einem Schritt zurück.
|
||||||
|
- Aufgaben: Strg+Z (Cmd+Z) macht Löschen, Verschieben (Spalten, Reihenfolge in Kanban und Liste) und Abhaken rückgängig. Nach dem Löschen erscheint unten „Aufgabe im Papierkorb · Rückgängig“, auch am Handy.
|
||||||
|
- Notizen, Aufgabenlisten und Listen lassen sich ohne Emoji anlegen: Alle Emoji-Auswähler haben „Entfernen“. Ohne Emoji steht der Titel allein; im Kopf einer Notiz zeigt ein dezentes Symbol, wo man eins wählen kann.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Kanban: Eine Aufgabe, die in eine selbst angelegte Erledigt-Spalte gezogen wurde, galt nach dem Umsortieren nicht als erledigt (nur die Standard-Spalte „Erledigt“ zählte).
|
||||||
|
|
||||||
## [0.44.0] - 2026-11-01
|
## [0.44.0] - 2026-11-01
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -718,7 +728,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.
|
- 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.
|
- 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.44.0...dev
|
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.45.0...dev
|
||||||
|
[0.45.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.44.0...v0.45.0
|
||||||
[0.44.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.43.0...v0.44.0
|
[0.44.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.43.0...v0.44.0
|
||||||
[0.43.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.42.0...v0.43.0
|
[0.43.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.42.0...v0.43.0
|
||||||
[0.42.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.41.0...v0.42.0
|
[0.42.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.41.0...v0.42.0
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](CHANGELOG.md)
|
[](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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.44.0",
|
"version": "0.45.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.44.0",
|
"version": "0.45.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "5.3.0",
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.44.0",
|
"version": "0.45.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
|
import { SmilePlus } from 'lucide-react';
|
||||||
import { EmojiPickerModal } from '../features/workspace/EmojiPickerModal';
|
import { EmojiPickerModal } from '../features/workspace/EmojiPickerModal';
|
||||||
|
|
||||||
interface EmojiIconButtonProps {
|
interface EmojiIconButtonProps {
|
||||||
value: string;
|
value: string;
|
||||||
|
// "" = no icon (removed in the picker)
|
||||||
onChange: (emoji: string) => void;
|
onChange: (emoji: string) => void;
|
||||||
// Shown while no icon is chosen
|
|
||||||
fallback: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Icon field of the create dialogs (note, task list, list): the emoji as a button that opens the same
|
// Icon field of the create dialogs (note, task list, list): the emoji as a button that opens the same
|
||||||
// picker as the icon of an open note (Doku/ui-patterns.md).
|
// picker as the icon of an open note, removing included (Doku/ui-patterns.md).
|
||||||
export const EmojiIconButton: React.FC<EmojiIconButtonProps> = ({ value, onChange, fallback }) => {
|
export const EmojiIconButton: React.FC<EmojiIconButtonProps> = ({ value, onChange }) => {
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
const [anchor, setAnchor] = useState<DOMRect | null>(null);
|
const [anchor, setAnchor] = useState<DOMRect | null>(null);
|
||||||
return (
|
return (
|
||||||
@@ -19,17 +19,16 @@ export const EmojiIconButton: React.FC<EmojiIconButtonProps> = ({ value, onChang
|
|||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAnchor((cur) => (cur ? null : buttonRef.current?.getBoundingClientRect() ?? null))}
|
onClick={() => setAnchor((cur) => (cur ? null : buttonRef.current?.getBoundingClientRect() ?? null))}
|
||||||
title="Icon auswählen"
|
title={value ? 'Icon ändern' : 'Icon wählen'}
|
||||||
aria-label="Icon auswählen"
|
aria-label={value ? 'Icon ändern' : 'Icon wählen'}
|
||||||
className="w-12 sm:w-10 h-11 sm:h-8 flex-shrink-0 flex items-center justify-center input-field text-lg sm:text-base leading-none cursor-pointer hover:border-strong"
|
className="w-12 sm:w-10 h-11 sm:h-8 flex-shrink-0 flex items-center justify-center input-field text-lg sm:text-base leading-none cursor-pointer hover:border-strong"
|
||||||
>
|
>
|
||||||
{value || fallback}
|
{value || <SmilePlus className="w-4 h-4 text-faint" strokeWidth={1.75} />}
|
||||||
</button>
|
</button>
|
||||||
{anchor && (
|
{anchor && (
|
||||||
<EmojiPickerModal
|
<EmojiPickerModal
|
||||||
currentIcon={value}
|
currentIcon={value}
|
||||||
anchor={anchor}
|
anchor={anchor}
|
||||||
allowRemove={false}
|
|
||||||
onSelectEmoji={(emoji) => {
|
onSelectEmoji={(emoji) => {
|
||||||
onChange(emoji);
|
onChange(emoji);
|
||||||
setAnchor(null);
|
setAnchor(null);
|
||||||
|
|||||||
@@ -257,8 +257,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
const selectedList = writableLists.find((l) => l.id === targetListId) || writableLists[0];
|
const selectedList = writableLists.find((l) => l.id === targetListId) || writableLists[0];
|
||||||
|
|
||||||
const getTargetLabel = () => {
|
const getTargetLabel = () => {
|
||||||
if (target === 'task') return selectedTaskList ? `${selectedTaskList.icon || '📋'} ${selectedTaskList.title}` : 'Direkt-Aufgabe';
|
if (target === 'task') return selectedTaskList ? (selectedTaskList.icon ? `${selectedTaskList.icon} ${selectedTaskList.title}` : selectedTaskList.title) : 'Direkt-Aufgabe';
|
||||||
if (target === 'list_item') return selectedList ? `${selectedList.icon} ${selectedList.title}` : 'Auf Liste';
|
if (target === 'list_item') return selectedList ? [selectedList.icon, selectedList.title].filter(Boolean).join(' ') : 'Auf Liste';
|
||||||
if (target === 'document') return 'Direkt-Notiz';
|
if (target === 'document') return 'Direkt-Notiz';
|
||||||
return 'Quick Dump';
|
return 'Quick Dump';
|
||||||
};
|
};
|
||||||
@@ -536,7 +536,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
|
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{tl.icon || '📋'}</span>
|
{tl.icon && <span>{tl.icon}</span>}
|
||||||
<span>{tl.title}</span>
|
<span>{tl.title}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -761,6 +761,8 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
|||||||
{ key: '↑ / ↓', scope: 'Workspace Outliner', desc: 'Zum vorherigen / nächsten Block navigieren' },
|
{ key: '↑ / ↓', scope: 'Workspace Outliner', desc: 'Zum vorherigen / nächsten Block navigieren' },
|
||||||
{ key: 'Strg + Z / Cmd + Z', scope: 'Workspace Outliner', desc: 'Letzte Notiz-Aktion rückgängig machen' },
|
{ key: 'Strg + Z / Cmd + Z', scope: 'Workspace Outliner', desc: 'Letzte Notiz-Aktion rückgängig machen' },
|
||||||
{ key: 'Strg + Y / Strg + Shift + Z', scope: 'Workspace Outliner', desc: 'Rückgängig gemachte Aktion wiederholen' },
|
{ key: 'Strg + Y / Strg + Shift + Z', scope: 'Workspace Outliner', desc: 'Rückgängig gemachte Aktion wiederholen' },
|
||||||
|
{ key: 'Maus über mehrere Punkte ziehen', scope: 'Workspace Outliner', desc: 'Text über mehrere Punkte markieren; Entf/Backspace löscht ihn, Tippen ersetzt ihn, Strg + C kopiert' },
|
||||||
|
{ key: 'Strg + Z / Cmd + Z', scope: 'Aufgaben', desc: 'Löschen, Verschieben oder Abhaken rückgängig machen' },
|
||||||
{ key: '/', scope: 'Workspace Outliner', desc: 'Slash-Menü öffnen (/todo, /link, /h1, /code, ...)' },
|
{ key: '/', scope: 'Workspace Outliner', desc: 'Slash-Menü öffnen (/todo, /link, /h1, /code, ...)' },
|
||||||
{ key: '[] oder [ ] am Anfang', scope: 'Workspace Outliner', desc: 'Sofort in Todo-Checkbox umwandeln' },
|
{ key: '[] oder [ ] am Anfang', scope: 'Workspace Outliner', desc: 'Sofort in Todo-Checkbox umwandeln' },
|
||||||
{ key: '[x] am Anfang', scope: 'Workspace Outliner', desc: 'Sofort in erledigte Todo-Checkbox umwandeln' },
|
{ key: '[x] am Anfang', scope: 'Workspace Outliner', desc: 'Sofort in erledigte Todo-Checkbox umwandeln' },
|
||||||
|
|||||||
@@ -1171,7 +1171,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 min-h-[54px] rounded-btn bg-panel border border-border !mt-0">
|
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 min-h-[54px] rounded-btn bg-panel border border-border !mt-0">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-sm font-semibold text-main truncate">
|
<span className="text-sm font-semibold text-main truncate">
|
||||||
{activeList ? `${activeList.icon} ${activeList.title}` : '🛒 Einkaufsliste'}
|
{activeList ? [activeList.icon, activeList.title].filter(Boolean).join(' ') : '🛒 Einkaufsliste'}
|
||||||
</span>
|
</span>
|
||||||
{openItems.length > 0 && (
|
{openItems.length > 0 && (
|
||||||
<span className="badge-neutral text-[11px] flex-shrink-0">
|
<span className="badge-neutral text-[11px] flex-shrink-0">
|
||||||
@@ -1204,7 +1204,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<EmojiIconButton value={newListIcon} onChange={setNewListIcon} fallback="🛒" />
|
<EmojiIconButton value={newListIcon} onChange={setNewListIcon} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newListTitle}
|
value={newListTitle}
|
||||||
@@ -1540,7 +1540,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
onClose={() => setShowShareModal(false)}
|
onClose={() => setShowShareModal(false)}
|
||||||
title={`Liste teilen: ${activeList.title}`}
|
title={`Liste teilen: ${activeList.title}`}
|
||||||
resourceName={activeList.title}
|
resourceName={activeList.title}
|
||||||
resourceIcon={activeList.icon || '🛒'}
|
resourceIcon={activeList.icon || undefined}
|
||||||
isOwner={activeList.isOwner !== false && (activeList.userId === currentUser?.id || !activeList.userId || currentUser?.role === 'admin')}
|
isOwner={activeList.isOwner !== false && (activeList.userId === currentUser?.id || !activeList.userId || currentUser?.role === 'admin')}
|
||||||
currentUserId={currentUser?.id}
|
currentUserId={currentUser?.id}
|
||||||
shares={(activeList.shares || []).map((s) => ({
|
shares={(activeList.shares || []).map((s) => ({
|
||||||
|
|||||||
@@ -606,7 +606,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
<option value="">-- Keine Liste (Allgemein) --</option>
|
<option value="">-- Keine Liste (Allgemein) --</option>
|
||||||
{taskLists.map((tl) => (
|
{taskLists.map((tl) => (
|
||||||
<option key={tl.id} value={tl.id}>
|
<option key={tl.id} value={tl.id}>
|
||||||
{tl.icon} {tl.title}
|
{[tl.icon, tl.title].filter(Boolean).join(' ')}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -596,8 +596,83 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
|
|
||||||
// --- TASK ACTIONS ---
|
// --- TASK ACTIONS ---
|
||||||
|
|
||||||
|
// --- UNDO (Strg+Z / Cmd+Z): deleting, moving and ticking off, newest first; deleted tasks come back
|
||||||
|
// from the task trash. Deleting also shows "Rückgängig" for phones.
|
||||||
|
type UndoEntry =
|
||||||
|
| { kind: 'delete'; ids: string[] }
|
||||||
|
| { kind: 'order'; snapshot: { id: string; sortOrder: number; kanbanStatus?: string; isCompleted: boolean }[] }
|
||||||
|
| { kind: 'status'; id: string; isCompleted: boolean; kanbanStatus?: string };
|
||||||
|
const undoStackRef = useRef<UndoEntry[]>([]);
|
||||||
|
const [toast, setToast] = useState<{ text: string; undo: boolean } | null>(null);
|
||||||
|
const pushUndo = (entry: UndoEntry) => {
|
||||||
|
undoStackRef.current = [...undoStackRef.current, entry].slice(-20);
|
||||||
|
setToast(null); // its "Rückgängig" would now undo this newer step
|
||||||
|
};
|
||||||
|
const orderSnapshot = () =>
|
||||||
|
tasks.map((t) => ({ id: t.id, sortOrder: t.sortOrder ?? 0, kanbanStatus: t.kanbanStatus, isCompleted: !!t.isCompleted }));
|
||||||
|
const toastTimerRef = useRef<number | null>(null);
|
||||||
|
const showToast = (text: string, undo = false) => {
|
||||||
|
setToast({ text, undo });
|
||||||
|
if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current);
|
||||||
|
toastTimerRef.current = window.setTimeout(() => setToast(null), undo ? 8000 : 3000);
|
||||||
|
};
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const undoLast = async () => {
|
||||||
|
const entry = undoStackRef.current.pop();
|
||||||
|
if (!entry) return;
|
||||||
|
setToast(null);
|
||||||
|
triggerHaptic(10);
|
||||||
|
try {
|
||||||
|
if (entry.kind === 'delete') {
|
||||||
|
for (const id of entry.ids) await api.restoreTrashedTask(id).catch(() => {});
|
||||||
|
showToast(entry.ids.length === 1 ? 'Aufgabe wiederhergestellt' : `${entry.ids.length} Aufgaben wiederhergestellt`);
|
||||||
|
} else if (entry.kind === 'order') {
|
||||||
|
const byId = new Map(entry.snapshot.map((x) => [x.id, x]));
|
||||||
|
setTasks((prev) =>
|
||||||
|
prev.map((t) => {
|
||||||
|
const old = byId.get(t.id);
|
||||||
|
return old ? { ...t, sortOrder: old.sortOrder, kanbanStatus: old.kanbanStatus, isCompleted: old.isCompleted } : t;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await api.reorderTasks(
|
||||||
|
entry.snapshot.map((x) => ({ id: x.id, sortOrder: x.sortOrder, kanbanStatus: x.kanbanStatus })),
|
||||||
|
selectedListId !== 'all' ? selectedListId : undefined
|
||||||
|
);
|
||||||
|
showToast('Verschieben rückgängig gemacht');
|
||||||
|
} else {
|
||||||
|
setTasks((prev) =>
|
||||||
|
prev.map((t) => (t.id === entry.id ? { ...t, isCompleted: entry.isCompleted, kanbanStatus: entry.kanbanStatus } : t))
|
||||||
|
);
|
||||||
|
await api.updateThought(entry.id, { isCompleted: entry.isCompleted, kanbanStatus: entry.kanbanStatus });
|
||||||
|
showToast('Rückgängig gemacht');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await loadData();
|
||||||
|
onRefreshStats();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const undoLastRef = useRef(undoLast);
|
||||||
|
undoLastRef.current = undoLast;
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (!(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey || e.key.toLowerCase() !== 'z') return;
|
||||||
|
// Text fields keep their own undo
|
||||||
|
const el = e.target as HTMLElement | null;
|
||||||
|
if (el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName))) return;
|
||||||
|
if (undoStackRef.current.length === 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
void undoLastRef.current();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleToggleDone = async (task: Thought) => {
|
const handleToggleDone = async (task: Thought) => {
|
||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
|
pushUndo({ kind: 'status', id: task.id, isCompleted: !!task.isCompleted, kanbanStatus: task.kanbanStatus });
|
||||||
const nextState = !task.isCompleted;
|
const nextState = !task.isCompleted;
|
||||||
const cols = columnsFor(task);
|
const cols = columnsFor(task);
|
||||||
const doneCol = cols.find((c) => c.isDone) || cols[cols.length - 1];
|
const doneCol = cols.find((c) => c.isDone) || cols[cols.length - 1];
|
||||||
@@ -615,6 +690,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
|
|
||||||
const handleSetKanbanStatus = async (task: Thought, nextStatus: string) => {
|
const handleSetKanbanStatus = async (task: Thought, nextStatus: string) => {
|
||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
|
pushUndo({ kind: 'status', id: task.id, isCompleted: !!task.isCompleted, kanbanStatus: task.kanbanStatus });
|
||||||
const targetCol = columnsFor(task).find((c) => c.id === nextStatus);
|
const targetCol = columnsFor(task).find((c) => c.id === nextStatus);
|
||||||
const isCompleted = targetCol ? !!targetCol.isDone : nextStatus === 'done';
|
const isCompleted = targetCol ? !!targetCol.isDone : nextStatus === 'done';
|
||||||
|
|
||||||
@@ -784,6 +860,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
const handleDeletePermanent = async (id: string) => {
|
const handleDeletePermanent = async (id: string) => {
|
||||||
triggerHaptic(20);
|
triggerHaptic(20);
|
||||||
await api.deleteThought(id);
|
await api.deleteThought(id);
|
||||||
|
pushUndo({ kind: 'delete', ids: [id] });
|
||||||
|
showToast('Aufgabe im Papierkorb', true);
|
||||||
await loadData();
|
await loadData();
|
||||||
onRefreshStats();
|
onRefreshStats();
|
||||||
};
|
};
|
||||||
@@ -803,7 +881,13 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
|
|
||||||
const handleClearDone = async () => {
|
const handleClearDone = async () => {
|
||||||
triggerHaptic(20);
|
triggerHaptic(20);
|
||||||
|
// The ones that will go (completed and editable here), for undo
|
||||||
|
const ids = doneTasks.filter((t) => !isTaskReadOnly(t)).map((t) => t.id);
|
||||||
await api.clearDoneTasks(selectedListId === 'all' ? '' : selectedListId);
|
await api.clearDoneTasks(selectedListId === 'all' ? '' : selectedListId);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
pushUndo({ kind: 'delete', ids });
|
||||||
|
showToast(ids.length === 1 ? 'Aufgabe im Papierkorb' : `${ids.length} Aufgaben im Papierkorb`, true);
|
||||||
|
}
|
||||||
await loadData();
|
await loadData();
|
||||||
onRefreshStats();
|
onRefreshStats();
|
||||||
};
|
};
|
||||||
@@ -967,6 +1051,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
sortOrder: idx,
|
sortOrder: idx,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
pushUndo({ kind: 'order', snapshot: orderSnapshot() });
|
||||||
setTasks(updatedWithSort);
|
setTasks(updatedWithSort);
|
||||||
setSortBy('custom');
|
setSortBy('custom');
|
||||||
|
|
||||||
@@ -1103,6 +1188,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
sortOrder: idx,
|
sortOrder: idx,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
pushUndo({ kind: 'order', snapshot: orderSnapshot() });
|
||||||
setTasks(updatedWithSort);
|
setTasks(updatedWithSort);
|
||||||
setSortBy('custom');
|
setSortBy('custom');
|
||||||
|
|
||||||
@@ -1333,7 +1419,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
},
|
},
|
||||||
...safeTaskLists.map((tl) => ({
|
...safeTaskLists.map((tl) => ({
|
||||||
id: `list-${tl.id}`,
|
id: `list-${tl.id}`,
|
||||||
label: `${tl.icon || '📋'} ${tl.title}`,
|
label: tl.icon ? `${tl.icon} ${tl.title}` : tl.title,
|
||||||
badge: tl.tasksCount || undefined,
|
badge: tl.tasksCount || undefined,
|
||||||
active: selectedListId === tl.id,
|
active: selectedListId === tl.id,
|
||||||
onClick: () => setSelectedListId(tl.id),
|
onClick: () => setSelectedListId(tl.id),
|
||||||
@@ -1846,7 +1932,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 min-h-[54px] rounded-btn bg-panel border border-border !mt-0">
|
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 min-h-[54px] rounded-btn bg-panel border border-border !mt-0">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-sm font-semibold text-main truncate">
|
<span className="text-sm font-semibold text-main truncate">
|
||||||
{currentSelectedList ? `${currentSelectedList.icon} ${currentSelectedList.title}` : '📋 Alle Aufgaben'}
|
{currentSelectedList ? [currentSelectedList.icon, currentSelectedList.title].filter(Boolean).join(' ') : '📋 Alle Aufgaben'}
|
||||||
</span>
|
</span>
|
||||||
<span className="badge-neutral text-[11px] flex-shrink-0">
|
<span className="badge-neutral text-[11px] flex-shrink-0">
|
||||||
{viewMode === 'kanban'
|
{viewMode === 'kanban'
|
||||||
@@ -2101,7 +2187,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
<option value="">Standard (Alle Aufgaben)</option>
|
<option value="">Standard (Alle Aufgaben)</option>
|
||||||
{safeTaskLists.map((tl) => (
|
{safeTaskLists.map((tl) => (
|
||||||
<option key={tl.id} value={tl.id}>
|
<option key={tl.id} value={tl.id}>
|
||||||
{tl.icon || '📋'} {tl.title}
|
{tl.icon ? `${tl.icon} ${tl.title}` : tl.title}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -2341,7 +2427,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<EmojiIconButton value={newListIcon} onChange={setNewListIcon} fallback="📋" />
|
<EmojiIconButton value={newListIcon} onChange={setNewListIcon} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newListTitle}
|
value={newListTitle}
|
||||||
@@ -2356,6 +2442,26 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</FormSheet>
|
</FormSheet>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Short notice after deleting or undoing (same look as in the workspace) */}
|
||||||
|
{toast && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-6 z-50 max-w-[calc(100vw-1.5rem)] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<span>{toast.text}</span>
|
||||||
|
{toast.undo && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void undoLast()}
|
||||||
|
className="font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0 shrink-0"
|
||||||
|
title="Rückgängig (Strg+Z)"
|
||||||
|
>
|
||||||
|
Rückgängig
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* UNIFIED SHARE LIST MODAL */}
|
{/* UNIFIED SHARE LIST MODAL */}
|
||||||
{showShareListModal && sharingTaskList && (
|
{showShareListModal && sharingTaskList && (
|
||||||
<UnifiedShareModal
|
<UnifiedShareModal
|
||||||
@@ -2366,7 +2472,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
}}
|
}}
|
||||||
title={`Aufgabenliste teilen: ${sharingTaskList.title}`}
|
title={`Aufgabenliste teilen: ${sharingTaskList.title}`}
|
||||||
resourceName={sharingTaskList.title}
|
resourceName={sharingTaskList.title}
|
||||||
resourceIcon={sharingTaskList.icon || '📋'}
|
resourceIcon={sharingTaskList.icon || undefined}
|
||||||
isOwner={sharingTaskList.isOwner !== false}
|
isOwner={sharingTaskList.isOwner !== false}
|
||||||
currentUserId={currentUser?.id}
|
currentUserId={currentUser?.id}
|
||||||
shares={(sharingTaskList.shares || []).map((sh) => ({
|
shares={(sharingTaskList.shares || []).map((sh) => ({
|
||||||
@@ -2817,7 +2923,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
<p className="text-[11px] text-faint truncate">
|
<p className="text-[11px] text-faint truncate">
|
||||||
{selectedListId === 'all' && (
|
{selectedListId === 'all' && (
|
||||||
<>
|
<>
|
||||||
{listOfItem ? `${listOfItem.icon || '📋'} ${listOfItem.title}` : 'Persönlich'}
|
{listOfItem ? (listOfItem.icon ? `${listOfItem.icon} ${listOfItem.title}` : listOfItem.title) : 'Persönlich'}
|
||||||
{' · '}
|
{' · '}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export const BacklinksSection: React.FC<BacklinksSectionProps> = ({ docId, title
|
|||||||
onClick={() => onOpen(l.docId)}
|
onClick={() => onOpen(l.docId)}
|
||||||
className="flex items-center gap-2 min-w-0 max-w-full text-left cursor-pointer group"
|
className="flex items-center gap-2 min-w-0 max-w-full text-left cursor-pointer group"
|
||||||
>
|
>
|
||||||
<span className="shrink-0">{l.icon || '🗒️'}</span>
|
{l.icon && <span className="shrink-0">{l.icon}</span>}
|
||||||
<span className="text-sm font-semibold text-main truncate group-hover:text-accent">
|
<span className="text-sm font-semibold text-main truncate group-hover:text-accent">
|
||||||
{l.title || 'Unbenannte Notiz'}
|
{l.title || 'Unbenannte Notiz'}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
Redo2,
|
Redo2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
|
SmilePlus,
|
||||||
} 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';
|
||||||
@@ -708,6 +709,19 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
[docId, canEdit, applyLocalBlocks]
|
[docId, canEdit, applyLocalBlocks]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A selection over several bullets was deleted (OutlinerTree): one change, so Strg+Z undoes it at once
|
||||||
|
const handleReplaceRange = useCallback(
|
||||||
|
(first: DocumentBlock, deleteIds: string[], movedChildren: DocumentBlock[]) => {
|
||||||
|
if (!canEdit) return;
|
||||||
|
const changed = new Map([first, ...movedChildren].map((b) => [b.id, b]));
|
||||||
|
const gone = new Set(deleteIds);
|
||||||
|
applyLocalBlocks((prev) =>
|
||||||
|
ensureNonEmptyBlocks(docId, prev.filter((b) => !gone.has(b.id)).map((b) => changed.get(b.id) || b))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[docId, canEdit, applyLocalBlocks]
|
||||||
|
);
|
||||||
|
|
||||||
// Swaps a bullet (with its children) with the previous or next one on the same level, within the same
|
// Swaps a bullet (with its children) with the previous or next one on the same level, within the same
|
||||||
// parent. By id, not by index, so it also works while zoomed into a branch (the tree then sees a slice).
|
// parent. By id, not by index, so it also works while zoomed into a branch (the tree then sees a slice).
|
||||||
const handleMoveSibling = useCallback(
|
const handleMoveSibling = useCallback(
|
||||||
@@ -1254,6 +1268,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onCreateBlock={handleCreateBlock}
|
onCreateBlock={handleCreateBlock}
|
||||||
onInsertBlocks={handleInsertBlocks}
|
onInsertBlocks={handleInsertBlocks}
|
||||||
onDeleteBlock={handleDeleteBlock}
|
onDeleteBlock={handleDeleteBlock}
|
||||||
|
onReplaceRange={handleReplaceRange}
|
||||||
onMoveBlock={handleMoveBlock}
|
onMoveBlock={handleMoveBlock}
|
||||||
onMoveSibling={handleMoveSibling}
|
onMoveSibling={handleMoveSibling}
|
||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
@@ -1286,12 +1301,15 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
title={inJournal ? undefined : 'Icon ändern'}
|
title={inJournal ? undefined : 'Icon ändern'}
|
||||||
>
|
>
|
||||||
{/* Journal days: a quiet calendar in the theme's text colour instead of the colourful emoji */}
|
{/* Journal days: a quiet calendar in the theme's text colour instead of the colourful emoji;
|
||||||
|
a note without an icon shows a faint "choose icon" to editors only */}
|
||||||
{inJournal ? (
|
{inJournal ? (
|
||||||
<CalendarDays className="w-6 h-6 sm:w-7 sm:h-7 text-muted" strokeWidth={1.75} />
|
<CalendarDays className="w-6 h-6 sm:w-7 sm:h-7 text-muted" strokeWidth={1.75} />
|
||||||
) : (
|
) : doc?.icon ? (
|
||||||
doc?.icon || '🗒️'
|
doc.icon
|
||||||
)}
|
) : canEdit ? (
|
||||||
|
<SmilePlus className="w-6 h-6 sm:w-7 sm:h-7 text-faint" strokeWidth={1.5} aria-label="Icon wählen" />
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
{showEmojiPicker && canEdit && (
|
{showEmojiPicker && canEdit && (
|
||||||
<EmojiPickerModal
|
<EmojiPickerModal
|
||||||
@@ -1352,6 +1370,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onCreateBlock={handleCreateBlock}
|
onCreateBlock={handleCreateBlock}
|
||||||
onInsertBlocks={handleInsertBlocks}
|
onInsertBlocks={handleInsertBlocks}
|
||||||
onDeleteBlock={handleDeleteBlock}
|
onDeleteBlock={handleDeleteBlock}
|
||||||
|
onReplaceRange={handleReplaceRange}
|
||||||
onMoveBlock={handleMoveBlock}
|
onMoveBlock={handleMoveBlock}
|
||||||
onMoveSibling={handleMoveSibling}
|
onMoveSibling={handleMoveSibling}
|
||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ interface EmojiPickerModalProps {
|
|||||||
// Inside a dialog (which clips and transforms its content): the picker is put on the page instead and
|
// Inside a dialog (which clips and transforms its content): the picker is put on the page instead and
|
||||||
// opens below this button on desktop
|
// opens below this button on desktop
|
||||||
anchor?: DOMRect | null;
|
anchor?: DOMRect | null;
|
||||||
// Offer "Icon entfernen" (notes; a create dialog only has its default icon to go back to)
|
|
||||||
allowRemove?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||||
@@ -38,7 +36,6 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
|||||||
onSelectEmoji,
|
onSelectEmoji,
|
||||||
onClose,
|
onClose,
|
||||||
anchor,
|
anchor,
|
||||||
allowRemove = true,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { inset, visibleHeight } = useKeyboardInset();
|
const { inset, visibleHeight } = useKeyboardInset();
|
||||||
const isPhone = typeof window !== 'undefined' && window.innerWidth < 640;
|
const isPhone = typeof window !== 'undefined' && window.innerWidth < 640;
|
||||||
@@ -151,7 +148,7 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{currentIcon && allowRemove && (
|
{currentIcon && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelectEmoji('')}
|
onClick={() => onSelectEmoji('')}
|
||||||
@@ -205,8 +202,8 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop: removing the icon as a quiet last row, like an entry of a context menu */}
|
{/* Desktop: removing the icon (none at all) as a quiet last row, like an entry of a context menu */}
|
||||||
{currentIcon && allowRemove && (
|
{currentIcon && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelectEmoji('')}
|
onClick={() => onSelectEmoji('')}
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ export const LinkModal: React.FC<LinkModalProps> = ({
|
|||||||
className="flex items-center justify-between p-2.5 bg-canvas border border-border rounded-card hover:border-accent/60 transition-colors group cursor-pointer"
|
className="flex items-center justify-between p-2.5 bg-canvas border border-border rounded-card hover:border-accent/60 transition-colors group cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5 min-w-0">
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
<span className="text-base shrink-0">{d.icon || '🗒️'}</span>
|
{d.icon && <span className="text-base shrink-0">{d.icon}</span>}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-xs font-semibold text-main truncate group-hover:text-accent">
|
<div className="text-xs font-semibold text-main truncate group-hover:text-accent">
|
||||||
{d.title || 'Unbenannte Notiz'}
|
{d.title || 'Unbenannte Notiz'}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export const MergeDialog: React.FC<MergeDialogProps> = ({ docs, sourceId, target
|
|||||||
onClick={() => setTarget(d.id)}
|
onClick={() => setTarget(d.id)}
|
||||||
className="w-full min-h-[44px] sm:min-h-0 px-3 py-2 flex items-center gap-2 text-left text-xs text-main hover:bg-panel-elevated"
|
className="w-full min-h-[44px] sm:min-h-0 px-3 py-2 flex items-center gap-2 text-left text-xs text-main hover:bg-panel-elevated"
|
||||||
>
|
>
|
||||||
<span className="shrink-0">{d.icon || '🗒️'}</span>
|
{d.icon && <span className="shrink-0">{d.icon}</span>}
|
||||||
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
||||||
{d.folderPath && <span className="text-[10px] text-faint truncate max-w-[40%]">{d.folderPath}</span>}
|
{d.folderPath && <span className="text-[10px] text-faint truncate max-w-[40%]">{d.folderPath}</span>}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -125,6 +125,8 @@ export interface OutlinerBlockProps {
|
|||||||
onResolveConflict?: (blockId: string, accept: boolean) => void;
|
onResolveConflict?: (blockId: string, accept: boolean) => void;
|
||||||
onUndo?: () => void;
|
onUndo?: () => void;
|
||||||
onRedo?: () => void;
|
onRedo?: () => void;
|
||||||
|
/** Part of a selection over several bullets (browsers without the CSS Highlight API tint the row) */
|
||||||
|
inRangeSelection?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rendered markdown only drops syntax characters (**, `, [..](url) …), so its text is a subsequence of the raw
|
// Rendered markdown only drops syntax characters (**, `, [..](url) …), so its text is a subsequence of the raw
|
||||||
@@ -175,6 +177,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
onResolveConflict,
|
onResolveConflict,
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
|
inRangeSelection,
|
||||||
}) => {
|
}) => {
|
||||||
const [localContent, setLocalContent] = useState<string>(block.content || '');
|
const [localContent, setLocalContent] = useState<string>(block.content || '');
|
||||||
const [isLocallyFocused, setIsLocallyFocused] = useState<boolean>(false);
|
const [isLocallyFocused, setIsLocallyFocused] = useState<boolean>(false);
|
||||||
@@ -1077,7 +1080,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
data-outline-block={block.id}
|
||||||
className={`group relative flex items-start gap-1 py-1 px-0.5 sm:px-1.5 rounded-card transition-all duration-300 ${
|
className={`group relative flex items-start gap-1 py-1 px-0.5 sm:px-1.5 rounded-card transition-all duration-300 ${
|
||||||
|
inRangeSelection ? 'bg-accent/15' : ''
|
||||||
|
} ${
|
||||||
block.type === 'h1'
|
block.type === 'h1'
|
||||||
? 'mt-2.5 sm:mt-3'
|
? 'mt-2.5 sm:mt-3'
|
||||||
: block.type === 'h2'
|
: block.type === 'h2'
|
||||||
@@ -1387,7 +1393,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* When actively focused, ghost text is invisible so textarea renders text/caret directly */}
|
{/* When actively focused, ghost text is invisible so textarea renders text/caret directly */}
|
||||||
<span ref={renderedTextRef} className={activeFocus ? 'invisible' : ''}>
|
<span ref={renderedTextRef} data-outline-text className={activeFocus ? 'invisible' : ''}>
|
||||||
{previewUrl && !activeFocus ? (
|
{previewUrl && !activeFocus ? (
|
||||||
// The card above shows the page; the address itself becomes a small link marker
|
// The card above shows the page; the address itself becomes a small link marker
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||||
import { OutlinerBlock } from './OutlinerBlock';
|
import { OutlinerBlock, mapRenderedOffsetToRaw } from './OutlinerBlock';
|
||||||
import { parsePastedLines } from '../../lib/pasteOutline';
|
import { parsePastedLines } from '../../lib/pasteOutline';
|
||||||
import { numberedLabels } from '../../lib/outlineList';
|
import { numberedLabels } from '../../lib/outlineList';
|
||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
@@ -39,6 +39,9 @@ export interface OutlinerTreeProps {
|
|||||||
onResolveConflict?: (blockId: string, accept: boolean) => void;
|
onResolveConflict?: (blockId: string, accept: boolean) => void;
|
||||||
onUndo?: () => void;
|
onUndo?: () => void;
|
||||||
onRedo?: () => void;
|
onRedo?: () => void;
|
||||||
|
/** Deleting a selection over several bullets in one step: the first bullet with its new text, the
|
||||||
|
* bullets to delete, and children of the last bullet that move under the first */
|
||||||
|
onReplaceRange?: (first: DocumentBlock, deleteIds: string[], movedChildren: DocumentBlock[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||||
@@ -67,6 +70,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
onResolveConflict,
|
onResolveConflict,
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
|
onReplaceRange,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -665,8 +669,245 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
return () => window.removeEventListener('dragend', clearDrag);
|
return () => window.removeEventListener('dragend', clearDrag);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// --- Selection over several bullets ---
|
||||||
|
// Each bullet is a text field of its own, so the browser cannot select across them. Dragging from one
|
||||||
|
// bullet into another is tracked here instead: start (anchor) and end (focus) as bullet + position in
|
||||||
|
// its raw text. The start bullet keeps the browser's own selection; the others are highlighted with the
|
||||||
|
// CSS Highlight API (rows are tinted where it is missing). Delete/Backspace removes the selected text
|
||||||
|
// like a text editor in one step (Strg+Z brings it back), a typed character replaces it, Strg+C copies.
|
||||||
|
type RangeSel = { anchorId: string; anchorOffset: number; focusId: string; focusOffset: number; focusNode?: Node; focusNodeOffset?: number };
|
||||||
|
const [rangeSel, setRangeSel] = useState<RangeSel | null>(null);
|
||||||
|
const rangeSelRef = useRef<RangeSel | null>(null);
|
||||||
|
rangeSelRef.current = rangeSel;
|
||||||
|
const treeRootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dragAnchorRef = useRef<{ id: string; offset: number } | null>(null);
|
||||||
|
const highlightSupported = typeof CSS !== 'undefined' && 'highlights' in CSS && typeof (window as any).Highlight === 'function';
|
||||||
|
|
||||||
|
const caretAtPoint = (x: number, y: number): { node: Node; offset: number } | null => {
|
||||||
|
const doc = document as any;
|
||||||
|
if (typeof doc.caretPositionFromPoint === 'function') {
|
||||||
|
const p = doc.caretPositionFromPoint(x, y);
|
||||||
|
return p ? { node: p.offsetNode, offset: p.offset } : null;
|
||||||
|
}
|
||||||
|
if (typeof doc.caretRangeFromPoint === 'function') {
|
||||||
|
const r = doc.caretRangeFromPoint(x, y);
|
||||||
|
return r ? { node: r.startContainer, offset: r.startOffset } : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Position in the raw text of a bullet for a point in its rendered text (outside it: start or end)
|
||||||
|
const rawOffsetAtPoint = (row: HTMLElement, id: string, x: number, y: number) => {
|
||||||
|
const raw = textareaMapRef.current.get(id)?.value ?? blocks.find((b) => b.id === id)?.content ?? '';
|
||||||
|
const span = row.querySelector<HTMLElement>('[data-outline-text]');
|
||||||
|
const caret = caretAtPoint(x, y);
|
||||||
|
if (span && caret && span.contains(caret.node)) {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(span, 0);
|
||||||
|
range.setEnd(caret.node, caret.offset);
|
||||||
|
return { offset: mapRenderedOffsetToRaw(raw, span.textContent || '', range.toString().length), node: caret.node, nodeOffset: caret.offset };
|
||||||
|
}
|
||||||
|
const rect = (span || row).getBoundingClientRect();
|
||||||
|
const before = y < rect.top || (y <= rect.bottom && x < rect.left);
|
||||||
|
return { offset: before ? 0 : raw.length };
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearRangeSel = useCallback(() => {
|
||||||
|
dragAnchorRef.current = null;
|
||||||
|
setRangeSel(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Highlight the selected text of the bullets other than the start one
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlightSupported) return;
|
||||||
|
const registry = (CSS as any).highlights;
|
||||||
|
if (!rangeSel || !treeRootRef.current) {
|
||||||
|
registry.delete('outline-range');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const order = visibleEntries.map((e) => e.block.id);
|
||||||
|
const a = order.indexOf(rangeSel.anchorId);
|
||||||
|
const f = order.indexOf(rangeSel.focusId);
|
||||||
|
if (a < 0 || f < 0) {
|
||||||
|
registry.delete('outline-range');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ranges: Range[] = [];
|
||||||
|
const spanOf = (id: string) =>
|
||||||
|
treeRootRef.current!.querySelector<HTMLElement>(`[data-outline-block="${id}"] [data-outline-text]`);
|
||||||
|
for (let i = Math.min(a, f); i <= Math.max(a, f); i++) {
|
||||||
|
const id = order[i];
|
||||||
|
if (id === rangeSel.anchorId) continue; // shown by the browser in its text field
|
||||||
|
const span = spanOf(id);
|
||||||
|
if (!span) continue;
|
||||||
|
const r = document.createRange();
|
||||||
|
r.selectNodeContents(span);
|
||||||
|
if (id === rangeSel.focusId && rangeSel.focusNode && span.contains(rangeSel.focusNode)) {
|
||||||
|
if (f > a) r.setEnd(rangeSel.focusNode, rangeSel.focusNodeOffset ?? 0);
|
||||||
|
else r.setStart(rangeSel.focusNode, rangeSel.focusNodeOffset ?? 0);
|
||||||
|
}
|
||||||
|
ranges.push(r);
|
||||||
|
}
|
||||||
|
registry.set('outline-range', new (window as any).Highlight(...ranges));
|
||||||
|
return () => registry.delete('outline-range');
|
||||||
|
}, [rangeSel, visibleEntries, highlightSupported]);
|
||||||
|
|
||||||
|
// Mouse: a drag that leaves its bullet becomes a selection over several bullets
|
||||||
|
useEffect(() => {
|
||||||
|
const onMove = (e: MouseEvent) => {
|
||||||
|
const start = dragAnchorRef.current;
|
||||||
|
if (!start || !(e.buttons & 1)) return;
|
||||||
|
const row = (document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest<HTMLElement>('[data-outline-block]');
|
||||||
|
if (!row || !treeRootRef.current?.contains(row)) return;
|
||||||
|
const id = row.dataset.outlineBlock!;
|
||||||
|
if (id === start.id) {
|
||||||
|
if (rangeSelRef.current) setRangeSel(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const at = rawOffsetAtPoint(row, id, e.clientX, e.clientY);
|
||||||
|
setRangeSel({ anchorId: start.id, anchorOffset: start.offset, focusId: id, focusOffset: at.offset, focusNode: at.node, focusNodeOffset: at.nodeOffset });
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
dragAnchorRef.current = null;
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
window.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
// rawOffsetAtPoint only reads refs and blocks
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [blocks]);
|
||||||
|
|
||||||
|
const handleTreeMouseDownCapture = (e: React.MouseEvent) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
setRangeSel(null);
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const row = target.closest<HTMLElement>('[data-outline-block]');
|
||||||
|
if (!row || target.closest('button, a, input, select, [contenteditable="true"]')) {
|
||||||
|
dragAnchorRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = row.dataset.outlineBlock!;
|
||||||
|
// The bullet's own handler places the caret first; read it right after
|
||||||
|
dragAnchorRef.current = { id, offset: rawOffsetAtPoint(row, id, e.clientX, e.clientY).offset };
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const ta = textareaMapRef.current.get(id);
|
||||||
|
if (dragAnchorRef.current?.id === id && ta && document.activeElement === ta) {
|
||||||
|
dragAnchorRef.current = { id, offset: ta.selectionStart };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// The selection in text order: first bullet + offset, last bullet + offset, bullets in between
|
||||||
|
const resolveRangeSel = (sel: RangeSel) => {
|
||||||
|
const order = visibleEntries.map((en) => en.block.id);
|
||||||
|
const a = order.indexOf(sel.anchorId);
|
||||||
|
const f = order.indexOf(sel.focusId);
|
||||||
|
if (a < 0 || f < 0 || a === f) return null;
|
||||||
|
const [firstId, firstOffset, lastId, lastOffset] =
|
||||||
|
a < f ? [sel.anchorId, sel.anchorOffset, sel.focusId, sel.focusOffset] : [sel.focusId, sel.focusOffset, sel.anchorId, sel.anchorOffset];
|
||||||
|
const fIdx = blocks.findIndex((b) => b.id === firstId);
|
||||||
|
const lIdx = blocks.findIndex((b) => b.id === lastId);
|
||||||
|
if (fIdx < 0 || lIdx < 0 || lIdx <= fIdx) return null;
|
||||||
|
return { first: blocks[fIdx], firstOffset, last: blocks[lIdx], lastOffset, fIdx, lIdx };
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedText = (sel: RangeSel) => {
|
||||||
|
const r = resolveRangeSel(sel);
|
||||||
|
if (!r) return '';
|
||||||
|
const lines = [(r.first.content || '').slice(r.firstOffset)];
|
||||||
|
for (let i = r.fIdx + 1; i < r.lIdx; i++) lines.push(blocks[i].content || '');
|
||||||
|
lines.push((r.last.content || '').slice(0, r.lastOffset));
|
||||||
|
return lines.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteRangeSel = (sel: RangeSel, insert = '') => {
|
||||||
|
const r = resolveRangeSel(sel);
|
||||||
|
if (!r || !canEdit) return false;
|
||||||
|
const notMergeable = ['code', 'divider', 'table', 'progress'];
|
||||||
|
if (notMergeable.includes(r.first.type) || notMergeable.includes(r.last.type)) return false;
|
||||||
|
const deleted = blocks.slice(r.fIdx + 1, r.lIdx + 1);
|
||||||
|
const holder = [r.first, ...deleted].map((b) => lockHolder(b.id)).find(Boolean);
|
||||||
|
if (holder) {
|
||||||
|
onLockedAction?.(holder);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Children of the last bullet stay and move under the first, like merging two bullets
|
||||||
|
const lastLevel = Math.max(0, r.last.level || 0);
|
||||||
|
const delta = Math.max(0, r.first.level || 0) - lastLevel;
|
||||||
|
const moved: DocumentBlock[] = [];
|
||||||
|
for (let j = r.lIdx + 1; j < blocks.length; j++) {
|
||||||
|
const lvl = Math.max(0, blocks[j].level || 0);
|
||||||
|
if (lvl <= lastLevel) break;
|
||||||
|
moved.push({ ...blocks[j], level: Math.max(levelOffset || 0, lvl + delta) });
|
||||||
|
}
|
||||||
|
const merged: DocumentBlock = {
|
||||||
|
...r.first,
|
||||||
|
content: (r.first.content || '').slice(0, r.firstOffset) + insert + (r.last.content || '').slice(r.lastOffset),
|
||||||
|
};
|
||||||
|
const deleteIds = deleted.map((b) => b.id);
|
||||||
|
if (onReplaceRange) {
|
||||||
|
onReplaceRange(merged, deleteIds, moved);
|
||||||
|
} else {
|
||||||
|
if (onUpdateBlocks) onUpdateBlocks([merged, ...moved]);
|
||||||
|
else [merged, ...moved].forEach((b) => onUpdateBlock(b, true));
|
||||||
|
deleteIds.forEach((id) => onDeleteBlock(id));
|
||||||
|
}
|
||||||
|
clearRangeSel();
|
||||||
|
focusBlockInput(r.first.id, r.firstOffset + insert.length);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keys while a selection over several bullets exists
|
||||||
|
const deleteRangeSelRef = useRef(deleteRangeSel);
|
||||||
|
deleteRangeSelRef.current = deleteRangeSel;
|
||||||
|
const selectedTextRef = useRef(selectedText);
|
||||||
|
selectedTextRef.current = selectedText;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rangeSel) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const sel = rangeSelRef.current;
|
||||||
|
if (!sel) return;
|
||||||
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
|
const stop = () => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
if (mod && (e.key === 'c' || e.key === 'x')) {
|
||||||
|
stop();
|
||||||
|
void navigator.clipboard?.writeText(selectedTextRef.current(sel)).catch(() => {});
|
||||||
|
if (e.key === 'x') deleteRangeSelRef.current(sel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||||
|
stop();
|
||||||
|
deleteRangeSelRef.current(sel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mod && !e.altKey && e.key.length === 1) {
|
||||||
|
stop();
|
||||||
|
deleteRangeSelRef.current(sel, e.key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!['Shift', 'Control', 'Meta', 'Alt'].includes(e.key)) clearRangeSel();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey, true);
|
||||||
|
return () => window.removeEventListener('keydown', onKey, true);
|
||||||
|
}, [rangeSel, clearRangeSel]);
|
||||||
|
|
||||||
|
const rangeRowIds = useMemo(() => {
|
||||||
|
if (!rangeSel || highlightSupported) return null;
|
||||||
|
const order = visibleEntries.map((e) => e.block.id);
|
||||||
|
const a = order.indexOf(rangeSel.anchorId);
|
||||||
|
const f = order.indexOf(rangeSel.focusId);
|
||||||
|
if (a < 0 || f < 0) return null;
|
||||||
|
return new Set(order.slice(Math.min(a, f), Math.max(a, f) + 1));
|
||||||
|
}, [rangeSel, visibleEntries, highlightSupported]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5" ref={treeRootRef} onMouseDownCapture={handleTreeMouseDownCapture}>
|
||||||
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }, entryIdx) => {
|
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }, entryIdx) => {
|
||||||
const isLast = entryIdx === visibleEntries.length - 1;
|
const isLast = entryIdx === visibleEntries.length - 1;
|
||||||
return (
|
return (
|
||||||
@@ -709,6 +950,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
isDragTarget={dragOverIndex === index && draggedIndex !== index}
|
isDragTarget={dragOverIndex === index && draggedIndex !== index}
|
||||||
isTargeted={highlightedBlockId === block.id}
|
isTargeted={highlightedBlockId === block.id}
|
||||||
registerTextareaRef={registerTextareaRef}
|
registerTextareaRef={registerTextareaRef}
|
||||||
|
inRangeSelection={rangeRowIds?.has(block.id)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore,
|
|||||||
className="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
|
className="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
|
||||||
title="Schreibgeschützt ansehen"
|
title="Schreibgeschützt ansehen"
|
||||||
>
|
>
|
||||||
<span className="text-lg shrink-0">{t.icon || '🗒️'}</span>
|
{t.icon && <span className="text-lg shrink-0">{t.icon}</span>}
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block text-sm font-semibold text-main truncate group-hover:text-accent">
|
<span className="block text-sm font-semibold text-main truncate group-hover:text-accent">
|
||||||
{t.title || 'Unbenannte Notiz'}
|
{t.title || 'Unbenannte Notiz'}
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
|||||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="shrink-0">{d.icon || '🗒️'}</span>
|
{d.icon && <span className="shrink-0">{d.icon}</span>}
|
||||||
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -589,7 +589,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
try {
|
try {
|
||||||
const created = await api.createDocument({
|
const created = await api.createDocument({
|
||||||
title: title.trim() || 'Neue Notiz',
|
title: title.trim() || 'Neue Notiz',
|
||||||
icon: icon.trim() || '🗒️',
|
icon: icon.trim(),
|
||||||
folderPath,
|
folderPath,
|
||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
@@ -1420,7 +1420,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
map.set(d.id, {
|
map.set(d.id, {
|
||||||
docId: d.id,
|
docId: d.id,
|
||||||
title: d.title || 'Unbenannte Notiz',
|
title: d.title || 'Unbenannte Notiz',
|
||||||
icon: d.icon || '🗒️',
|
icon: d.icon || '',
|
||||||
folderPath: d.folderPath || '',
|
folderPath: d.folderPath || '',
|
||||||
matchedBlocks: matched,
|
matchedBlocks: matched,
|
||||||
});
|
});
|
||||||
@@ -1433,7 +1433,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
map.set(dg.docId, {
|
map.set(dg.docId, {
|
||||||
docId: dg.docId,
|
docId: dg.docId,
|
||||||
title: dg.title || 'Unbenannte Notiz',
|
title: dg.title || 'Unbenannte Notiz',
|
||||||
icon: dg.icon || '🗒️',
|
icon: dg.icon || '',
|
||||||
folderPath: dg.folderPath || '',
|
folderPath: dg.folderPath || '',
|
||||||
matchedBlocks: dg.matchedBlocks || [],
|
matchedBlocks: dg.matchedBlocks || [],
|
||||||
});
|
});
|
||||||
@@ -1806,7 +1806,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
className="flex-1 min-w-0 truncate text-left text-xs font-medium text-main hover:text-accent cursor-pointer"
|
className="flex-1 min-w-0 truncate text-left text-xs font-medium text-main hover:text-accent cursor-pointer"
|
||||||
title="Andere Notiz wählen"
|
title="Andere Notiz wählen"
|
||||||
>
|
>
|
||||||
{selectedDocObj?.icon} {selectedDocObj?.title}
|
{[selectedDocObj?.icon, selectedDocObj?.title].filter(Boolean).join(' ')}
|
||||||
</button>
|
</button>
|
||||||
<div ref={setZenActionsTarget} className="flex items-center" />
|
<div ref={setZenActionsTarget} className="flex items-center" />
|
||||||
<button
|
<button
|
||||||
@@ -1990,7 +1990,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="shrink-0">{d.icon || '🗒️'}</span>
|
{d.icon && <span className="shrink-0">{d.icon}</span>}
|
||||||
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
||||||
{d.sharedWith && d.sharedWith.length > 0 && (
|
{d.sharedWith && d.sharedWith.length > 0 && (
|
||||||
<Users className="w-3 h-3 text-status-ok shrink-0" />
|
<Users className="w-3 h-3 text-status-ok shrink-0" />
|
||||||
@@ -2061,7 +2061,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="shrink-0">{d.icon || '🗒️'}</span>
|
{d.icon && <span className="shrink-0">{d.icon}</span>}
|
||||||
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
||||||
{d.sharedWith && d.sharedWith.length > 0 && (
|
{d.sharedWith && d.sharedWith.length > 0 && (
|
||||||
<Users className="w-3 h-3 text-status-ok shrink-0" />
|
<Users className="w-3 h-3 text-status-ok shrink-0" />
|
||||||
@@ -2363,7 +2363,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
className="inline-flex items-center gap-1.5 text-[11px] px-2 py-0.5 rounded-btn bg-panel-elevated text-main truncate max-w-full"
|
className="inline-flex items-center gap-1.5 text-[11px] px-2 py-0.5 rounded-btn bg-panel-elevated text-main truncate max-w-full"
|
||||||
title={meta.docs[0].title}
|
title={meta.docs[0].title}
|
||||||
>
|
>
|
||||||
<span>{meta.docs[0].icon || '🗒️'}</span>
|
{meta.docs[0].icon && <span>{meta.docs[0].icon}</span>}
|
||||||
<span className="truncate">{meta.docs[0].title || 'Unbenannte Notiz'}</span>
|
<span className="truncate">{meta.docs[0].title || 'Unbenannte Notiz'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2464,7 +2464,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
onClick={() => handleJumpToDocBlock(docGroup.docId)}
|
onClick={() => handleJumpToDocBlock(docGroup.docId)}
|
||||||
className="flex items-center gap-2 text-xs font-semibold text-accent hover:underline cursor-pointer"
|
className="flex items-center gap-2 text-xs font-semibold text-accent hover:underline cursor-pointer"
|
||||||
>
|
>
|
||||||
<span>{docGroup.icon || '🗒️'}</span>
|
{docGroup.icon && <span>{docGroup.icon}</span>}
|
||||||
<span>{docGroup.title}</span>
|
<span>{docGroup.title}</span>
|
||||||
{docGroup.folderPath && (
|
{docGroup.folderPath && (
|
||||||
<span className="badge-neutral text-[10px] font-normal">
|
<span className="badge-neutral text-[10px] font-normal">
|
||||||
@@ -2714,7 +2714,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-btn bg-main/[0.06] text-muted hover:text-main"
|
className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-btn bg-main/[0.06] text-muted hover:text-main"
|
||||||
title={item.doc.title}
|
title={item.doc.title}
|
||||||
>
|
>
|
||||||
<span>{item.doc.icon || '🗒️'}</span>
|
{item.doc.icon && <span>{item.doc.icon}</span>}
|
||||||
<span className="truncate max-w-[120px]">{item.doc.title}</span>
|
<span className="truncate max-w-[120px]">{item.doc.title}</span>
|
||||||
{item.doc.folderPath && (
|
{item.doc.folderPath && (
|
||||||
<span className="text-faint text-[10px]">({item.doc.folderPath})</span>
|
<span className="text-faint text-[10px]">({item.doc.folderPath})</span>
|
||||||
@@ -2815,7 +2815,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<ChevronDown className="w-3 h-3 text-accent" />
|
<ChevronDown className="w-3 h-3 text-accent" />
|
||||||
)}
|
)}
|
||||||
<span>{doc.icon || '🗒️'}</span>
|
{doc.icon && <span>{doc.icon}</span>}
|
||||||
<span>{doc.title}</span>
|
<span>{doc.title}</span>
|
||||||
<span className="badge-neutral text-[9px] font-mono">
|
<span className="badge-neutral text-[9px] font-mono">
|
||||||
{docOpenCount} offen
|
{docOpenCount} offen
|
||||||
@@ -2967,7 +2967,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<EmojiIconButton value={newNoteIcon} onChange={setNewNoteIcon} fallback="🗒️" />
|
<EmojiIconButton value={newNoteIcon} onChange={setNewNoteIcon} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
autoFocus
|
autoFocus
|
||||||
|
|||||||
@@ -606,6 +606,11 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Selection over several bullets in a note (OutlinerTree, CSS Highlight API) */
|
||||||
|
::highlight(outline-range) {
|
||||||
|
background-color: color-mix(in srgb, var(--accent-primary) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
/* Read-only sections (e.g. server settings in the demo): everything inside looks switched off */
|
/* Read-only sections (e.g. server settings in the demo): everything inside looks switched off */
|
||||||
fieldset:disabled :is(button, input, select, textarea) {
|
fieldset:disabled :is(button, input, select, textarea) {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|||||||
@@ -953,11 +953,12 @@ export const api = {
|
|||||||
|
|
||||||
async createList(titleOrSpaceId: string, titleOrIcon?: string, iconOrType?: string, listType?: 'shopping' | 'checklist' | 'vacation'): Promise<SharedList> {
|
async createList(titleOrSpaceId: string, titleOrIcon?: string, iconOrType?: string, listType?: 'shopping' | 'checklist' | 'vacation'): Promise<SharedList> {
|
||||||
let title = titleOrSpaceId;
|
let title = titleOrSpaceId;
|
||||||
let icon = titleOrIcon || '🛒';
|
// An empty icon means "no icon"; only a missing one gets the default
|
||||||
|
let icon = titleOrIcon ?? '🛒';
|
||||||
let type = (iconOrType as any) || 'shopping';
|
let type = (iconOrType as any) || 'shopping';
|
||||||
if (listType !== undefined) {
|
if (listType !== undefined) {
|
||||||
title = titleOrIcon || '';
|
title = titleOrIcon || '';
|
||||||
icon = iconOrType || '🛒';
|
icon = iconOrType ?? '🛒';
|
||||||
type = listType;
|
type = listType;
|
||||||
}
|
}
|
||||||
const l = await request<SharedList>('/api/v1/lists', {
|
const l = await request<SharedList>('/api/v1/lists', {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
|
|||||||
var req struct {
|
var req struct {
|
||||||
SpaceID string `json:"spaceId"`
|
SpaceID string `json:"spaceId"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Icon string `json:"icon"`
|
Icon *string `json:"icon"` // "" = no icon; missing = the default
|
||||||
FolderPath string `json:"folderPath"`
|
FolderPath string `json:"folderPath"`
|
||||||
Blocks []domain.DocumentBlock `json:"blocks"`
|
Blocks []domain.DocumentBlock `json:"blocks"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
@@ -58,7 +58,12 @@ func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
doc, err := s.store.CreateDocument(userID, req.SpaceID, req.Title, req.Icon, req.FolderPath, req.Blocks, req.Tags, req.IsPinned)
|
doc, err := s.store.CreateDocument(userID, req.SpaceID, req.Title, derefIcon(req.Icon), req.FolderPath, req.Blocks, req.Tags, req.IsPinned)
|
||||||
|
if err == nil && doc != nil && req.Icon != nil && *req.Icon == "" {
|
||||||
|
if err = s.store.ClearIcon("documents", doc.ID); err == nil {
|
||||||
|
doc.Icon = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
@@ -330,3 +335,11 @@ func (s *Server) docTitle(userID, docID string) string {
|
|||||||
}
|
}
|
||||||
return "Unbenannte Notiz"
|
return "Unbenannte Notiz"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// derefIcon: the icon of a create request; a missing one lets the store pick the default.
|
||||||
|
func derefIcon(icon *string) string {
|
||||||
|
if icon == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *icon
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) {
|
|||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
var req struct {
|
var req struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Icon string `json:"icon"`
|
Icon *string `json:"icon"` // "" = no icon; missing = the default
|
||||||
ListType string `json:"listType"`
|
ListType string `json:"listType"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -47,7 +47,12 @@ func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
lst, err := s.store.CreateList(userID, req.Title, req.Icon, req.ListType)
|
lst, err := s.store.CreateList(userID, req.Title, derefIcon(req.Icon), req.ListType)
|
||||||
|
if err == nil && lst != nil && req.Icon != nil && *req.Icon == "" {
|
||||||
|
if err = s.store.ClearIcon("lists", lst.ID); err == nil {
|
||||||
|
lst.Icon = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -28,14 +28,19 @@ func (s *Server) handleCreateTaskList(w http.ResponseWriter, r *http.Request) {
|
|||||||
SpaceID string `json:"spaceId"`
|
SpaceID string `json:"spaceId"`
|
||||||
TeamID string `json:"teamId"`
|
TeamID string `json:"teamId"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Icon string `json:"icon"`
|
Icon *string `json:"icon"` // "" = no icon; missing = the default
|
||||||
Color string `json:"color"`
|
Color string `json:"color"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tl, err := s.store.CreateTaskList(userID, req.SpaceID, req.TeamID, req.Title, req.Icon, req.Color)
|
tl, err := s.store.CreateTaskList(userID, req.SpaceID, req.TeamID, req.Title, derefIcon(req.Icon), req.Color)
|
||||||
|
if err == nil && tl != nil && req.Icon != nil && *req.Icon == "" {
|
||||||
|
if err = s.store.ClearIcon("task_lists", tl.ID); err == nil {
|
||||||
|
tl.Icon = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -958,3 +958,15 @@ func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) {
|
|||||||
}
|
}
|
||||||
return users, nil
|
return users, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClearIcon removes the icon of a note, task list or list (created on purpose without one; the create
|
||||||
|
// functions put in a default for an empty icon).
|
||||||
|
func (s *Store) ClearIcon(table, id string) error {
|
||||||
|
switch table {
|
||||||
|
case "documents", "task_lists", "lists":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown table %q", table)
|
||||||
|
}
|
||||||
|
_, err := s.db.Exec(`UPDATE `+table+` SET icon = '' WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sandrodach/mindsift/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func inTaskTrash(s *Store, id string) bool {
|
func inTaskTrash(s *Store, id string) bool {
|
||||||
@@ -215,3 +217,31 @@ func TestPurgeExpiredAreas(t *testing.T) {
|
|||||||
t.Fatal("things from before the update count from the update on")
|
t.Fatal("things from before the update count from the update on")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Moving a card into a list's own done column (any ID) completes the task, other columns reopen it.
|
||||||
|
func TestReorderIntoCustomDoneColumn(t *testing.T) {
|
||||||
|
s, err := NewStore(filepath.Join(t.TempDir(), "donecol.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
anna, _ := s.CreateUser("anna@example.com", "hash", "Anna", "member")
|
||||||
|
list, _ := s.CreateTaskList(anna.ID, "", "", "Projekt", "", "")
|
||||||
|
cols := []domain.KanbanColumn{{ID: "todo", Name: "Offen"}, {ID: "col_fertig", Name: "Fertig", IsDone: true}}
|
||||||
|
if _, err := s.UpdateTaskList(anna.ID, list.ID, nil, nil, nil, nil, &cols); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
task, _ := s.CreateThoughtWithDetails(anna.ID, "Bericht", "normal", "manual", "", list.ID, "todo", "")
|
||||||
|
_, _ = s.UpdateThoughtTriage(anna.ID, task.ID, "keep", "task", "", "", list.ID)
|
||||||
|
done, open := "col_fertig", "todo"
|
||||||
|
if err := s.ReorderTasks(list.ID, []domain.ThoughtReorderItem{{ID: task.ID, SortOrder: 0, KanbanStatus: &done}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, _ := s.GetThoughtByIDUnchecked(task.ID); !got.IsCompleted {
|
||||||
|
t.Fatal("the list's done column should complete the task")
|
||||||
|
}
|
||||||
|
_ = s.ReorderTasks(list.ID, []domain.ThoughtReorderItem{{ID: task.ID, SortOrder: 0, KanbanStatus: &open}})
|
||||||
|
if got, _ := s.GetThoughtByIDUnchecked(task.ID); got.IsCompleted {
|
||||||
|
t.Fatal("an open column reopens it")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -238,7 +238,7 @@ func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderIte
|
|||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
if it.KanbanStatus != nil && *it.KanbanStatus != "" {
|
if it.KanbanStatus != nil && *it.KanbanStatus != "" {
|
||||||
isCompleted := 0
|
isCompleted := 0
|
||||||
if *it.KanbanStatus == "done" {
|
if isDoneColumn(tx, it.ID, *it.KanbanStatus) {
|
||||||
isCompleted = 1
|
isCompleted = 1
|
||||||
var was int
|
var was int
|
||||||
_ = tx.QueryRow(`SELECT is_completed FROM thoughts WHERE id = ?`, it.ID).Scan(&was)
|
_ = tx.QueryRow(`SELECT is_completed FROM thoughts WHERE id = ?`, it.ID).Scan(&was)
|
||||||
@@ -275,6 +275,24 @@ func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderIte
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isDoneColumn: whether a Kanban column completes the task: the column of the task's list marked as done
|
||||||
|
// (its ID may be anything), else the default ID "done".
|
||||||
|
func isDoneColumn(q interface {
|
||||||
|
QueryRow(query string, args ...any) *sql.Row
|
||||||
|
}, taskID, column string) bool {
|
||||||
|
var raw string
|
||||||
|
_ = q.QueryRow(`SELECT COALESCE(tl.columns, '') FROM thoughts t LEFT JOIN task_lists tl ON tl.id = t.task_list_id WHERE t.id = ?`, taskID).Scan(&raw)
|
||||||
|
var cols []domain.KanbanColumn
|
||||||
|
if raw != "" && json.Unmarshal([]byte(raw), &cols) == nil && len(cols) > 0 {
|
||||||
|
for _, c := range cols {
|
||||||
|
if c.ID == column {
|
||||||
|
return c.IsDone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return column == "done"
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateThoughtTriage handles swipe left (discard), swipe right (keep), restore, and conversion to task/document/list_item
|
// UpdateThoughtTriage handles swipe left (discard), swipe right (keep), restore, and conversion to task/document/list_item
|
||||||
func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, targetListID, dueDate, taskListID string) (*domain.Thought, error) {
|
func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, targetListID, dueDate, taskListID string) (*domain.Thought, error) {
|
||||||
thought, err := s.GetThoughtByID(userID, thoughtID)
|
thought, err := s.GetThoughtByID(userID, thoughtID)
|
||||||
|
|||||||
Reference in new issue
Block a user