feat: complete workspace, triage, outliner and sharing overhaul (tasks 1-26)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
This commit is contained in:
1 parent
df2f2bac67
commit
d39d1d1122
17 files changed
+1102
-430
No files matched your search
@@ -1,5 +1,14 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
<rect width="512" height="512" rx="128" fill="#0b0f19"/>
|
<rect width="512" height="512" rx="128" fill="#8ab4f8"/>
|
||||||
<circle cx="256" cy="256" r="164" fill="none" stroke="#8ab4f8" stroke-width="36"/>
|
<g transform="translate(80, 80) scale(14.666)" fill="none" stroke="#0b0f19" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M184 266 L236 318 L338 194" fill="none" stroke="#c58af9" stroke-width="40" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/>
|
||||||
|
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/>
|
||||||
|
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/>
|
||||||
|
<path d="M17.599 6.5a3 3 0 0 0 .399-1.375"/>
|
||||||
|
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"/>
|
||||||
|
<path d="M3.477 10.896a4 4 0 0 1 .585-.396"/>
|
||||||
|
<path d="M19.938 10.5a4 4 0 0 1 .585.396"/>
|
||||||
|
<path d="M6 18a4 4 0 0 1-1.967-.516"/>
|
||||||
|
<path d="M19.967 17.484A4 4 0 0 1 18 18"/>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 374 B After Width: | Height: | Size: 851 B |
+15
-6
@@ -127,6 +127,7 @@ export const App: React.FC = () => {
|
|||||||
const [activeTab, setActiveTab] = useState<ActiveTab>('triage');
|
const [activeTab, setActiveTab] = useState<ActiveTab>('triage');
|
||||||
const [previousTab, setPreviousTab] = useState<ActiveTab>('triage');
|
const [previousTab, setPreviousTab] = useState<ActiveTab>('triage');
|
||||||
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
|
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
|
||||||
|
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
||||||
const [captureFocusCount, setCaptureFocusCount] = useState(0);
|
const [captureFocusCount, setCaptureFocusCount] = useState(0);
|
||||||
const [refreshRevision, setRefreshRevision] = useState(0);
|
const [refreshRevision, setRefreshRevision] = useState(0);
|
||||||
|
|
||||||
@@ -143,6 +144,7 @@ export const App: React.FC = () => {
|
|||||||
const [mobileVoiceActive, setMobileVoiceActive] = useState(false);
|
const [mobileVoiceActive, setMobileVoiceActive] = useState(false);
|
||||||
const [isVoiceCaptured, setIsVoiceCaptured] = useState(false);
|
const [isVoiceCaptured, setIsVoiceCaptured] = useState(false);
|
||||||
const mobileRecognitionRef = useRef<any>(null);
|
const mobileRecognitionRef = useRef<any>(null);
|
||||||
|
const mobileBaseVoiceRef = useRef('');
|
||||||
|
|
||||||
const toggleMobileVoice = () => {
|
const toggleMobileVoice = () => {
|
||||||
const SpeechRecognition =
|
const SpeechRecognition =
|
||||||
@@ -157,6 +159,7 @@ export const App: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
mobileBaseVoiceRef.current = mobileTriageContent.replace(/\s*\[…\]$/, '').trim();
|
||||||
const recognition = new SpeechRecognition();
|
const recognition = new SpeechRecognition();
|
||||||
recognition.lang = 'de-DE';
|
recognition.lang = 'de-DE';
|
||||||
recognition.continuous = false;
|
recognition.continuous = false;
|
||||||
@@ -170,10 +173,8 @@ export const App: React.FC = () => {
|
|||||||
for (let i = 0; i < event.results.length; i++) {
|
for (let i = 0; i < event.results.length; i++) {
|
||||||
transcript += event.results[i][0].transcript;
|
transcript += event.results[i][0].transcript;
|
||||||
}
|
}
|
||||||
setMobileTriageContent((prev) => {
|
const base = mobileBaseVoiceRef.current;
|
||||||
const base = prev.replace(/\s*\[…\]$/, '').trim();
|
setMobileTriageContent(base ? `${base} ${transcript}` : transcript);
|
||||||
return base ? `${base} ${transcript}` : transcript;
|
|
||||||
});
|
|
||||||
setIsVoiceCaptured(true);
|
setIsVoiceCaptured(true);
|
||||||
};
|
};
|
||||||
recognition.onerror = () => setMobileVoiceActive(false);
|
recognition.onerror = () => setMobileVoiceActive(false);
|
||||||
@@ -549,8 +550,9 @@ export const App: React.FC = () => {
|
|||||||
refreshAll();
|
refreshAll();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenDocument = (docId: string) => {
|
const handleOpenDocument = (docId: string, blockId?: string) => {
|
||||||
setSelectedDocId(docId);
|
setSelectedDocId(docId);
|
||||||
|
setSelectedBlockId(blockId || null);
|
||||||
setActiveTab('workspace');
|
setActiveTab('workspace');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -636,9 +638,12 @@ export const App: React.FC = () => {
|
|||||||
<Brain className="w-4 h-4" />
|
<Brain className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="font-bold text-base text-main tracking-tight">
|
<div className="flex flex-col">
|
||||||
|
<span className="font-bold text-base text-main tracking-tight leading-none">
|
||||||
{authStatus.instanceName || 'MindSift'}
|
{authStatus.instanceName || 'MindSift'}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">v0.1</span>
|
||||||
|
</div>
|
||||||
<span className="text-[11px] font-semibold text-accent bg-accent/15 px-1.5 py-0.5 rounded-md">
|
<span className="text-[11px] font-semibold text-accent bg-accent/15 px-1.5 py-0.5 rounded-md">
|
||||||
{activeTabLabelMap[activeTab]}
|
{activeTabLabelMap[activeTab]}
|
||||||
</span>
|
</span>
|
||||||
@@ -698,9 +703,12 @@ export const App: React.FC = () => {
|
|||||||
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
|
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
|
||||||
<Brain className="w-4 h-4" />
|
<Brain className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
|
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
|
||||||
{authStatus.instanceName || 'MindSift'}
|
{authStatus.instanceName || 'MindSift'}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">v0.1</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
|
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
|
||||||
@@ -961,6 +969,7 @@ export const App: React.FC = () => {
|
|||||||
<WorkspaceView
|
<WorkspaceView
|
||||||
currentUser={user}
|
currentUser={user}
|
||||||
initialDocId={selectedDocId}
|
initialDocId={selectedDocId}
|
||||||
|
initialBlockId={selectedBlockId}
|
||||||
onStatsChange={refreshAll}
|
onStatsChange={refreshAll}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const containerRef = useRef<HTMLFormElement>(null);
|
const containerRef = useRef<HTMLFormElement>(null);
|
||||||
const recognitionRef = useRef<any>(null);
|
const recognitionRef = useRef<any>(null);
|
||||||
|
const baseVoiceTextRef = useRef('');
|
||||||
|
const hasVoiceInputRef = useRef(false);
|
||||||
|
|
||||||
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
|
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
|
||||||
const targetEl = el || inputRef.current;
|
const targetEl = el || inputRef.current;
|
||||||
@@ -131,6 +133,9 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
baseVoiceTextRef.current = content.replace(/\s*\[…\]$/, '').trim();
|
||||||
|
hasVoiceInputRef.current = true;
|
||||||
|
|
||||||
const recognition = new SpeechRecognition();
|
const recognition = new SpeechRecognition();
|
||||||
recognition.lang = 'de-DE';
|
recognition.lang = 'de-DE';
|
||||||
recognition.continuous = false;
|
recognition.continuous = false;
|
||||||
@@ -146,10 +151,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
for (let i = 0; i < event.results.length; i++) {
|
for (let i = 0; i < event.results.length; i++) {
|
||||||
transcript += event.results[i][0].transcript;
|
transcript += event.results[i][0].transcript;
|
||||||
}
|
}
|
||||||
setContent((prev) => {
|
const base = baseVoiceTextRef.current;
|
||||||
const base = prev.replace(/\s*\[…\]$/, '').trim();
|
setContent(base ? `${base} ${transcript}` : transcript);
|
||||||
return base ? `${base} ${transcript}` : transcript;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
recognition.onerror = () => {
|
recognition.onerror = () => {
|
||||||
@@ -180,7 +183,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : '');
|
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : '');
|
||||||
const capturedPriority = priority;
|
const capturedPriority = priority;
|
||||||
const capturedDueDate = dueDate;
|
const capturedDueDate = dueDate;
|
||||||
const capturedSource = isListening ? 'voice' : 'manual';
|
const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual';
|
||||||
|
hasVoiceInputRef.current = false;
|
||||||
|
|
||||||
// Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers
|
// Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers
|
||||||
setContent('');
|
setContent('');
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
const [newItemText, setNewItemText] = useState('');
|
const [newItemText, setNewItemText] = useState('');
|
||||||
const [newItemQty, setNewItemQty] = useState('');
|
const [newItemQty, setNewItemQty] = useState('');
|
||||||
const [isSubmittingItem, setIsSubmittingItem] = useState(false);
|
const [isSubmittingItem, setIsSubmittingItem] = useState(false);
|
||||||
|
const [editingQtyItemId, setEditingQtyItemId] = useState<string | null>(null);
|
||||||
|
const [editingQtyValue, setEditingQtyValue] = useState('');
|
||||||
|
|
||||||
const [wsConnected, setWsConnected] = useState(false);
|
const [wsConnected, setWsConnected] = useState(false);
|
||||||
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>('none');
|
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>('none');
|
||||||
@@ -174,7 +176,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
return { ...l, items: deduplicateItems([newItem, ...l.items]) };
|
return { ...l, items: deduplicateItems([newItem, ...l.items]) };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else if (msg.event === 'ITEM_TOGGLED' && msgListId) {
|
} else if ((msg.event === 'ITEM_TOGGLED' || msg.event === 'ITEM_UPDATED') && msgListId) {
|
||||||
const updatedItem: ListItem = msg.payload;
|
const updatedItem: ListItem = msg.payload;
|
||||||
setLists((prev) =>
|
setLists((prev) =>
|
||||||
prev.map((l) => {
|
prev.map((l) => {
|
||||||
@@ -273,6 +275,26 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveQuantity = async (itemId: string, quantity: string) => {
|
||||||
|
setEditingQtyItemId(null);
|
||||||
|
if (!activeList) return;
|
||||||
|
const trimmed = quantity.trim();
|
||||||
|
try {
|
||||||
|
const updated = await api.updateListItem(activeList.id, itemId, { quantity: trimmed });
|
||||||
|
setLists((prev) =>
|
||||||
|
prev.map((l) => {
|
||||||
|
if (l.id !== activeList.id) return l;
|
||||||
|
return {
|
||||||
|
...l,
|
||||||
|
items: l.items.map((i) => (i.id === itemId ? { ...i, quantity: updated.quantity } : i)),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Fehler beim Aktualisieren der Menge', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggleItem = async (item: ListItem) => {
|
const handleToggleItem = async (item: ListItem) => {
|
||||||
if (!activeList) return;
|
if (!activeList) return;
|
||||||
triggerHaptic(10);
|
triggerHaptic(10);
|
||||||
@@ -1085,8 +1107,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newItemQty}
|
value={newItemQty}
|
||||||
onChange={(e) => setNewItemQty(e.target.value)}
|
onChange={(e) => setNewItemQty(e.target.value.replace(/[^0-9.,xX]/g, ''))}
|
||||||
placeholder="Menge (z.B. 2x)"
|
placeholder="Menge (z.B. 2)"
|
||||||
className="flex-1 sm:flex-none sm:w-28 h-9 sm:h-7 px-2.5 sm:px-2 input-field text-xs"
|
className="flex-1 sm:flex-none sm:w-28 h-9 sm:h-7 px-2.5 sm:px-2 input-field text-xs"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -1196,10 +1218,45 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
{displayContent}
|
{displayContent}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{item.quantity && (
|
{editingQtyItemId === item.id ? (
|
||||||
<span className="badge-accent">
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
{item.quantity}
|
<input
|
||||||
</span>
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
value={editingQtyValue}
|
||||||
|
onChange={(e) => setEditingQtyValue(e.target.value.replace(/[^0-9.,xX]/g, ''))}
|
||||||
|
onKeyDown={async (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
await handleSaveQuantity(item.id, editingQtyValue);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setEditingQtyItemId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={async () => {
|
||||||
|
await handleSaveQuantity(item.id, editingQtyValue);
|
||||||
|
}}
|
||||||
|
className="w-16 h-6 px-1.5 text-xs input-field font-semibold text-accent text-center"
|
||||||
|
placeholder="Menge"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditingQtyItemId(item.id);
|
||||||
|
setEditingQtyValue(item.quantity || '');
|
||||||
|
}}
|
||||||
|
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
|
||||||
|
item.quantity
|
||||||
|
? 'badge-accent cursor-pointer hover:brightness-125'
|
||||||
|
: 'opacity-0 group-hover:opacity-60 hover:!opacity-100 text-muted hover:text-accent border border-dashed border-border/70 hover:border-accent text-[11px]'
|
||||||
|
}`}
|
||||||
|
title="Menge bearbeiten"
|
||||||
|
>
|
||||||
|
{item.quantity || '+ Menge'}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{cat && !group.label && (
|
{cat && !group.label && (
|
||||||
@@ -1274,8 +1331,45 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
{displayContent}
|
{displayContent}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{item.quantity && (
|
{editingQtyItemId === item.id ? (
|
||||||
<span className="text-xs text-faint">({item.quantity})</span>
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
value={editingQtyValue}
|
||||||
|
onChange={(e) => setEditingQtyValue(e.target.value.replace(/[^0-9.,xX]/g, ''))}
|
||||||
|
onKeyDown={async (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
await handleSaveQuantity(item.id, editingQtyValue);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setEditingQtyItemId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={async () => {
|
||||||
|
await handleSaveQuantity(item.id, editingQtyValue);
|
||||||
|
}}
|
||||||
|
className="w-16 h-6 px-1.5 text-xs input-field font-semibold text-accent text-center"
|
||||||
|
placeholder="Menge"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditingQtyItemId(item.id);
|
||||||
|
setEditingQtyValue(item.quantity || '');
|
||||||
|
}}
|
||||||
|
className={`text-xs px-1.5 py-0.5 rounded transition-colors ${
|
||||||
|
item.quantity
|
||||||
|
? 'text-faint hover:text-main cursor-pointer'
|
||||||
|
: 'opacity-0 group-hover:opacity-60 text-faint hover:text-main text-[11px]'
|
||||||
|
}`}
|
||||||
|
title="Menge bearbeiten"
|
||||||
|
>
|
||||||
|
{item.quantity ? `(${item.quantity})` : '+ Menge'}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types';
|
import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types';
|
||||||
import { triggerHaptic } from '../../lib/api';
|
import { triggerHaptic } from '../../lib/api';
|
||||||
|
import { MarkdownViewer } from '../../lib/markdown';
|
||||||
|
|
||||||
interface TaskDetailModalProps {
|
interface TaskDetailModalProps {
|
||||||
task: Thought | null;
|
task: Thought | null;
|
||||||
@@ -36,6 +37,7 @@ interface TaskDetailModalProps {
|
|||||||
onAddSubtask: (task: Thought, title: string) => Promise<void>;
|
onAddSubtask: (task: Thought, title: string) => Promise<void>;
|
||||||
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
||||||
onConvertToDoc: (task: Thought) => void;
|
onConvertToDoc: (task: Thought) => void;
|
||||||
|
onOpenDocument?: (docId: string, blockId?: string) => void;
|
||||||
onDeleteTask: (taskId: string) => void;
|
onDeleteTask: (taskId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +58,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
onAddSubtask,
|
onAddSubtask,
|
||||||
onDeleteSubtask,
|
onDeleteSubtask,
|
||||||
onConvertToDoc,
|
onConvertToDoc,
|
||||||
|
onOpenDocument,
|
||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
}) => {
|
}) => {
|
||||||
if (!isOpen || !task) return null;
|
if (!isOpen || !task) return null;
|
||||||
@@ -202,6 +205,22 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
{(task.convertedType === 'document_block' || task.docId) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
const docId = task.docId || (task.convertedRefId ? task.convertedRefId.split(':')[0] : '');
|
||||||
|
const blockId = task.blockId || (task.convertedRefId ? task.convertedRefId.split(':')[1] : '');
|
||||||
|
if (docId && onOpenDocument) onOpenDocument(docId, blockId);
|
||||||
|
}}
|
||||||
|
title="Zur verlinkten Notiz springen"
|
||||||
|
className="btn-primary min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5 font-semibold"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
|
||||||
|
<span>In Notiz öffnen</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -214,6 +233,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-accent" />
|
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-accent" />
|
||||||
<span className="hidden sm:inline">Als Notiz vertiefen</span>
|
<span className="hidden sm:inline">Als Notiz vertiefen</span>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -656,10 +676,10 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
setNotesDraft(task.notes || '');
|
setNotesDraft(task.notes || '');
|
||||||
setIsEditingNotes(true);
|
setIsEditingNotes(true);
|
||||||
}}
|
}}
|
||||||
className="p-3 bg-canvas border border-border rounded-card text-xs text-muted leading-relaxed whitespace-pre-wrap cursor-pointer hover:border-strong transition-colors"
|
className="p-3 bg-canvas border border-border rounded-card text-xs text-muted leading-relaxed cursor-pointer hover:border-strong transition-colors"
|
||||||
title="Klicken zum Bearbeiten"
|
title="Klicken zum Bearbeiten"
|
||||||
>
|
>
|
||||||
{task.notes}
|
<MarkdownViewer content={task.notes} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const getInitials = (name?: string): string => {
|
|||||||
interface TasksViewProps {
|
interface TasksViewProps {
|
||||||
currentUser?: User | null;
|
currentUser?: User | null;
|
||||||
onRefreshStats: () => void;
|
onRefreshStats: () => void;
|
||||||
onOpenDocument: (docId: string) => void;
|
onOpenDocument: (docId: string, blockId?: string) => void;
|
||||||
refreshTrigger?: number;
|
refreshTrigger?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1135,6 +1135,23 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
className="flex flex-wrap items-center gap-1.5 pt-0.5 empty:hidden"
|
className="flex flex-wrap items-center gap-1.5 pt-0.5 empty:hidden"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
{(task.convertedType === 'document_block' || task.docId) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const docId = task.docId || (task.convertedRefId ? task.convertedRefId.split(':')[0] : '');
|
||||||
|
const blockId = task.blockId || (task.convertedRefId ? task.convertedRefId.split(':')[1] : '');
|
||||||
|
if (docId) onOpenDocument(docId, blockId);
|
||||||
|
}}
|
||||||
|
className="h-5 px-1.5 text-[10px] rounded-btn border border-accent/40 bg-accent/10 text-accent hover:bg-accent/20 cursor-pointer flex items-center gap-1 font-medium"
|
||||||
|
title="In Notiz an der passenden Stelle öffnen"
|
||||||
|
>
|
||||||
|
<FileText className="w-2.5 h-2.5" />
|
||||||
|
<span>In Notiz</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Kanban status selector pill (Desktop only - on mobile done via TaskDetailModal) */}
|
{/* Kanban status selector pill (Desktop only - on mobile done via TaskDetailModal) */}
|
||||||
<select
|
<select
|
||||||
value={task.kanbanStatus || (isCompleted ? 'done' : 'todo')}
|
value={task.kanbanStatus || (isCompleted ? 'done' : 'todo')}
|
||||||
@@ -2030,6 +2047,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
}}
|
}}
|
||||||
activeColumns={activeColumns}
|
activeColumns={activeColumns}
|
||||||
taskLists={safeTaskLists}
|
taskLists={safeTaskLists}
|
||||||
|
onOpenDocument={onOpenDocument}
|
||||||
onUpdateTitle={handleUpdateTaskTitle}
|
onUpdateTitle={handleUpdateTaskTitle}
|
||||||
onUpdateStatus={handleSetKanbanStatus}
|
onUpdateStatus={handleSetKanbanStatus}
|
||||||
onUpdatePriority={handleSetPriority}
|
onUpdatePriority={handleSetPriority}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Minimize2,
|
Minimize2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Plus,
|
Plus,
|
||||||
|
Search,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Document, DocumentBlock, User } from '../../lib/types';
|
import { Document, DocumentBlock, User } from '../../lib/types';
|
||||||
import { api, getToken } from '../../lib/api';
|
import { api, getToken } from '../../lib/api';
|
||||||
@@ -36,7 +37,63 @@ export interface DocumentEditorProps {
|
|||||||
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
|
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMOJI_PALETTE = ['📝', '📄', '💡', '🚀', '📌', '📂', '🧠', '⚡', '🎯', '🛠️', '📊', '🔒'];
|
interface EmojiItem {
|
||||||
|
emoji: string;
|
||||||
|
category: string;
|
||||||
|
keywords: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMOJI_CATALOG: EmojiItem[] = [
|
||||||
|
// Notizen & Dokumente
|
||||||
|
{ emoji: '📝', category: 'Notizen', keywords: 'notiz schreiben memo text note' },
|
||||||
|
{ emoji: '📄', category: 'Notizen', keywords: 'dokument seite page doc' },
|
||||||
|
{ emoji: '📋', category: 'Notizen', keywords: 'clipboard klemmbrett liste' },
|
||||||
|
{ emoji: '📂', category: 'Notizen', keywords: 'ordner mappe folder datei' },
|
||||||
|
{ emoji: '📁', category: 'Notizen', keywords: 'ordner folder directory' },
|
||||||
|
{ emoji: '📌', category: 'Notizen', keywords: 'pin anheften bookmark wichtig' },
|
||||||
|
{ emoji: '📍', category: 'Notizen', keywords: 'standort ort pin location' },
|
||||||
|
{ emoji: '🏷️', category: 'Notizen', keywords: 'tag etikett label markierung' },
|
||||||
|
{ emoji: '📚', category: 'Notizen', keywords: 'buch bibliothek lesen studium' },
|
||||||
|
{ emoji: '📖', category: 'Notizen', keywords: 'buch open book wissen' },
|
||||||
|
|
||||||
|
// Ideen & Fokus
|
||||||
|
{ emoji: '🧠', category: 'Ideen', keywords: 'gehirn brain denken wissen thought mind' },
|
||||||
|
{ emoji: '💡', category: 'Ideen', keywords: 'idee inspiration lampe glühbirne idea' },
|
||||||
|
{ emoji: '🎯', category: 'Ideen', keywords: 'ziel target fokus mission okr' },
|
||||||
|
{ emoji: '🚀', category: 'Ideen', keywords: 'rakete start launch projekt rocket' },
|
||||||
|
{ emoji: '⚡', category: 'Ideen', keywords: 'blitz energie schnell quick power' },
|
||||||
|
{ emoji: '🔥', category: 'Ideen', keywords: 'feuer flame priorität hot wichtig' },
|
||||||
|
{ emoji: '⭐', category: 'Ideen', keywords: 'stern favoriten star top' },
|
||||||
|
{ emoji: '✨', category: 'Ideen', keywords: 'funkeln magie neu neuigkeit sparkles' },
|
||||||
|
{ emoji: '🧩', category: 'Ideen', keywords: 'puzzle teil lösung problem' },
|
||||||
|
{ emoji: '🔬', category: 'Ideen', keywords: 'mikroskop forschung analyse test' },
|
||||||
|
|
||||||
|
// Tech & Dev
|
||||||
|
{ emoji: '💻', category: 'Tech', keywords: 'laptop computer dev code programming' },
|
||||||
|
{ emoji: '🖥️', category: 'Tech', keywords: 'desktop monitor bildschirm workstation' },
|
||||||
|
{ emoji: '🛠️', category: 'Tech', keywords: 'werkzeug tools optionen settings einstellungen' },
|
||||||
|
{ emoji: '⚙️', category: 'Tech', keywords: 'zahnrad config settings system' },
|
||||||
|
{ emoji: '📊', category: 'Tech', keywords: 'diagramm chart daten statistik dashboard' },
|
||||||
|
{ emoji: '📈', category: 'Tech', keywords: 'wachstum trend kurve business' },
|
||||||
|
{ emoji: '🔒', category: 'Tech', keywords: 'schloss sicherheit geheim privat secure' },
|
||||||
|
{ emoji: '🔑', category: 'Tech', keywords: 'schlüssel key passwort access' },
|
||||||
|
{ emoji: '🌐', category: 'Tech', keywords: 'web internet network welt globus' },
|
||||||
|
{ emoji: '📦', category: 'Tech', keywords: 'paket box release lieferung package' },
|
||||||
|
|
||||||
|
// Alltag & Organisation
|
||||||
|
{ emoji: '💬', category: 'Alltag', keywords: 'chat nachricht kommentar call sprechen' },
|
||||||
|
{ emoji: '✉️', category: 'Alltag', keywords: 'mail email brief post nachricht' },
|
||||||
|
{ emoji: '📅', category: 'Alltag', keywords: 'kalender datum termin plan event' },
|
||||||
|
{ emoji: '⏰', category: 'Alltag', keywords: 'wecker uhr zeit deadline alarm' },
|
||||||
|
{ emoji: '🛒', category: 'Alltag', keywords: 'warenkorb einkauf shopping liste' },
|
||||||
|
{ emoji: '☕', category: 'Alltag', keywords: 'kaffee pause bistro morning' },
|
||||||
|
{ emoji: '🏠', category: 'Alltag', keywords: 'haus heim home wohnung' },
|
||||||
|
{ emoji: '🏢', category: 'Alltag', keywords: 'büro firma arbeit office work' },
|
||||||
|
{ emoji: '✈️', category: 'Alltag', keywords: 'flugzeug reise urlaub trip travel' },
|
||||||
|
{ emoji: '🎨', category: 'Alltag', keywords: 'kunst farbe design creative' },
|
||||||
|
{ emoji: '🏆', category: 'Alltag', keywords: 'pokal erfolg win trophy award' },
|
||||||
|
{ emoji: '✅', category: 'Alltag', keywords: 'haken check done erledigt' },
|
||||||
|
];
|
||||||
|
|
||||||
function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] {
|
function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] {
|
||||||
if (!blocks || blocks.length === 0) {
|
if (!blocks || blocks.length === 0) {
|
||||||
@@ -162,8 +219,17 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
const [loading, setLoading] = useState<boolean>(!initialDoc);
|
const [loading, setLoading] = useState<boolean>(!initialDoc);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
||||||
|
const [emojiSearch, setEmojiSearch] = useState<string>('');
|
||||||
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
|
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const filteredEmojis = useMemo(() => {
|
||||||
|
const q = emojiSearch.trim().toLowerCase();
|
||||||
|
if (!q) return EMOJI_CATALOG;
|
||||||
|
return EMOJI_CATALOG.filter(
|
||||||
|
(item) => item.keywords.includes(q) || item.emoji.includes(q) || item.category.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [emojiSearch]);
|
||||||
|
|
||||||
// AutoSave state (REST persistence)
|
// AutoSave state (REST persistence)
|
||||||
const [, setSaveStatus] = useState<DocSaveStatus>('saved');
|
const [, setSaveStatus] = useState<DocSaveStatus>('saved');
|
||||||
const [, setSaveErrorMessage] = useState<string | null>(null);
|
const [, setSaveErrorMessage] = useState<string | null>(null);
|
||||||
@@ -172,7 +238,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
const lastSavedSnapshotRef = useRef<string>('');
|
const lastSavedSnapshotRef = useRef<string>('');
|
||||||
const isInitialLoadRef = useRef<boolean>(true);
|
const isInitialLoadRef = useRef<boolean>(true);
|
||||||
|
|
||||||
const titleInputRef = useRef<HTMLInputElement | null>(null);
|
const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
|
||||||
const docRef = useRef<Document | null>(doc);
|
const docRef = useRef<Document | null>(doc);
|
||||||
docRef.current = doc;
|
docRef.current = doc;
|
||||||
|
|
||||||
@@ -416,7 +482,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onDocMetadataChange(updatedDoc);
|
onDocMetadataChange(updatedDoc);
|
||||||
}, [blocks, extractedTags, title]);
|
}, [blocks, extractedTags, title]);
|
||||||
|
|
||||||
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
setTitle(e.target.value);
|
setTitle(e.target.value);
|
||||||
};
|
};
|
||||||
@@ -896,31 +962,81 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
{doc?.icon || '📝'}
|
{doc?.icon || '📝'}
|
||||||
</button>
|
</button>
|
||||||
{showEmojiPicker && canEdit && (
|
{showEmojiPicker && canEdit && (
|
||||||
<div className="absolute left-0 mt-1 p-2 panel-surface shadow-panel border border-border rounded-card grid grid-cols-6 gap-1 z-30 w-52">
|
<div
|
||||||
{EMOJI_PALETTE.map((emoji) => (
|
className="absolute left-0 mt-1 p-2 panel-surface shadow-lg border border-border rounded-card z-30 w-72 space-y-2"
|
||||||
<button
|
onClick={(e) => e.stopPropagation()}
|
||||||
key={emoji}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleSelectIcon(emoji)}
|
|
||||||
className="p-1.5 rounded-btn hover:bg-panel-elevated text-base flex items-center justify-center cursor-pointer"
|
|
||||||
>
|
>
|
||||||
{emoji}
|
<div className="relative">
|
||||||
|
<Search className="w-3.5 h-3.5 text-faint absolute left-2 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
value={emojiSearch}
|
||||||
|
onChange={(e) => setEmojiSearch(e.target.value)}
|
||||||
|
placeholder="Icon suchen oder Emoji eingeben…"
|
||||||
|
className="input-field w-full pl-7 pr-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{emojiSearch.trim() && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
handleSelectIcon(emojiSearch.trim());
|
||||||
|
setShowEmojiPicker(false);
|
||||||
|
setEmojiSearch('');
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-2 py-1 bg-accent/10 hover:bg-accent/20 text-accent rounded text-xs font-medium flex items-center justify-between cursor-pointer"
|
||||||
|
>
|
||||||
|
<span>Eingabe als Icon nutzen</span>
|
||||||
|
<span className="text-base">{emojiSearch.trim()}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="max-h-48 overflow-y-auto grid grid-cols-6 gap-1 pr-1">
|
||||||
|
{filteredEmojis.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.emoji}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
handleSelectIcon(item.emoji);
|
||||||
|
setShowEmojiPicker(false);
|
||||||
|
setEmojiSearch('');
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-btn hover:bg-panel-elevated text-lg flex items-center justify-center cursor-pointer transition-colors"
|
||||||
|
title={item.keywords}
|
||||||
|
>
|
||||||
|
{item.emoji}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{filteredEmojis.length === 0 && (
|
||||||
|
<div className="col-span-6 text-center text-xs text-faint py-3">
|
||||||
|
Kein passendes Standard-Icon
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<textarea
|
||||||
ref={titleInputRef}
|
ref={(el) => {
|
||||||
type="text"
|
titleInputRef.current = el;
|
||||||
|
if (el) {
|
||||||
|
el.style.height = 'auto';
|
||||||
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
rows={1}
|
||||||
value={title}
|
value={title}
|
||||||
readOnly={!canEdit}
|
readOnly={!canEdit}
|
||||||
autoFocus={autoFocusTitle}
|
autoFocus={autoFocusTitle}
|
||||||
onChange={handleTitleChange}
|
onChange={(e) => {
|
||||||
|
handleTitleChange(e);
|
||||||
|
e.target.style.height = 'auto';
|
||||||
|
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||||
|
}}
|
||||||
onBlur={handleTitleBlur}
|
onBlur={handleTitleBlur}
|
||||||
placeholder="Titel der Notiz…"
|
placeholder="Titel der Notiz…"
|
||||||
className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight"
|
className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight resize-none leading-snug whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Check,
|
Check,
|
||||||
|
CheckSquare,
|
||||||
X,
|
X,
|
||||||
|
Trash2,
|
||||||
|
MoreHorizontal,
|
||||||
Code,
|
Code,
|
||||||
Heading1,
|
Heading1,
|
||||||
Heading2,
|
Heading2,
|
||||||
@@ -19,6 +22,8 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||||
import { LinkModal } from './LinkModal';
|
import { LinkModal } from './LinkModal';
|
||||||
|
import { renderInlineMarkdown } from '../../lib/markdown';
|
||||||
|
import { api } from '../../lib/api';
|
||||||
|
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
type: BlockType | 'link';
|
type: BlockType | 'link';
|
||||||
@@ -50,7 +55,7 @@ export interface OutlinerBlockProps {
|
|||||||
onZoomBlock?: (blockId: string) => void;
|
onZoomBlock?: (blockId: string) => void;
|
||||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||||
onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void;
|
onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void;
|
||||||
onDeleteBlock: (blockId: string) => void;
|
onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void;
|
||||||
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
|
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
|
||||||
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
|
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
|
||||||
onTagClick?: (tag: string) => void;
|
onTagClick?: (tag: string) => void;
|
||||||
@@ -91,6 +96,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
const [slashSearch, setSlashSearch] = useState<string | null>(null);
|
const [slashSearch, setSlashSearch] = useState<string | null>(null);
|
||||||
const [slashIndex, setSlashIndex] = useState(0);
|
const [slashIndex, setSlashIndex] = useState(0);
|
||||||
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
|
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
|
||||||
|
const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false);
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const debounceTimerRef = useRef<number | null>(null);
|
const debounceTimerRef = useRef<number | null>(null);
|
||||||
@@ -475,23 +481,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
<span className="w-3.5" />
|
<span className="w-3.5" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{block.type === 'todo' ? (
|
{/* Bullet handle for dragging and zoom focus */}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!canEdit}
|
|
||||||
onClick={() =>
|
|
||||||
flushDebounce(localContent, { checked: !block.checked })
|
|
||||||
}
|
|
||||||
className={`w-4 h-4 checkbox-square flex items-center justify-center transition-colors border ${
|
|
||||||
block.checked
|
|
||||||
? 'bg-status-ok border-status-ok text-on-accent'
|
|
||||||
: 'border-strong hover:border-accent text-transparent'
|
|
||||||
}`}
|
|
||||||
title={block.checked ? 'Als offen markieren' : 'Als erledigt markieren'}
|
|
||||||
>
|
|
||||||
<Check className="w-3 h-3 stroke-[3]" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
draggable={canEdit}
|
draggable={canEdit}
|
||||||
@@ -516,6 +506,34 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* If To-Do block: checkbox alongside bullet */}
|
||||||
|
{block.type === 'todo' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canEdit}
|
||||||
|
onClick={() =>
|
||||||
|
flushDebounce(localContent, { checked: !block.checked })
|
||||||
|
}
|
||||||
|
className={`w-4 h-4 checkbox-square flex items-center justify-center transition-colors border ${
|
||||||
|
block.checked
|
||||||
|
? 'bg-status-ok border-status-ok text-on-accent'
|
||||||
|
: 'border-strong hover:border-accent text-transparent'
|
||||||
|
}`}
|
||||||
|
title={block.checked ? 'Als offen markieren' : 'Als erledigt markieren'}
|
||||||
|
>
|
||||||
|
<Check className="w-3 h-3 stroke-[3]" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{block.taskId && (
|
||||||
|
<span
|
||||||
|
className="badge-accent text-[9px] inline-flex items-center gap-0.5 px-1 py-0.2 rounded font-medium shrink-0"
|
||||||
|
title="Mit erweiterter Aufgabe synchronisiert"
|
||||||
|
>
|
||||||
|
<CheckSquare className="w-2.5 h-2.5" />
|
||||||
|
<span>Erweitert</span>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -571,6 +589,23 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
|
{!isFocused && localContent && (
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 cursor-text select-text pointer-events-none w-full leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
|
||||||
|
block.type === 'h1'
|
||||||
|
? 'text-lg font-bold text-main tracking-tight'
|
||||||
|
: block.type === 'h2'
|
||||||
|
? 'text-base font-semibold text-main'
|
||||||
|
: block.type === 'h3'
|
||||||
|
? 'text-sm font-semibold text-main'
|
||||||
|
: block.type === 'todo' && block.checked
|
||||||
|
? 'text-sm text-faint line-through'
|
||||||
|
: 'text-sm text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{renderInlineMarkdown(localContent)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
textareaRef.current = el;
|
textareaRef.current = el;
|
||||||
@@ -588,7 +623,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
? 'Schreibe einen Punkt… (Enter = Neu, Tab = Einrücken, Shift+Tab = Ausrücken, / für Befehle)'
|
? 'Schreibe einen Punkt… (Enter = Neu, Tab = Einrücken, Shift+Tab = Ausrücken, / für Befehle)'
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
className={`w-full resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words ${
|
className={`w-full resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
|
||||||
|
!isFocused && localContent ? 'text-transparent selection:text-transparent' : ''
|
||||||
|
} ${
|
||||||
block.type === 'h1'
|
block.type === 'h1'
|
||||||
? 'text-lg font-bold text-main tracking-tight'
|
? 'text-lg font-bold text-main tracking-tight'
|
||||||
: block.type === 'h2'
|
: block.type === 'h2'
|
||||||
@@ -712,6 +749,50 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Enhanced Task Option (3-dots) for To-Do Blocks */}
|
||||||
|
{canEdit && block.type === 'todo' && !block.taskId && (
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowTodoMenu((prev) => !prev);
|
||||||
|
}}
|
||||||
|
className="opacity-0 group-hover:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity cursor-pointer"
|
||||||
|
title="Aufgaben-Optionen"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
{showTodoMenu && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-full mt-1 bg-panel border border-border shadow-lg rounded-card p-1 z-30 w-52 text-xs"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
setShowTodoMenu(false);
|
||||||
|
try {
|
||||||
|
const docId = block.documentId || block.docId || '';
|
||||||
|
const thought = await api.createEnhancedTask(docId, block.id, {
|
||||||
|
content: localContent || 'Aufgabe',
|
||||||
|
checked: Boolean(block.checked),
|
||||||
|
});
|
||||||
|
flushDebounce(localContent, { taskId: thought.id });
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Fehler beim Erstellen der erweiterten Aufgabe');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-accent flex items-center gap-1.5 text-main font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
<CheckSquare className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span>In erweiterte Aufgabe umwandeln</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Zoom / Focus button on hover */}
|
{/* Zoom / Focus button on hover */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -722,15 +803,23 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
<Maximize2 className="w-3.5 h-3.5" />
|
<Maximize2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Delete button on hover */}
|
{/* Delete button on hover (Trash2 icon) */}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onDeleteBlockRef.current(block.id)}
|
onClick={() => {
|
||||||
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0"
|
if (hasChildren) {
|
||||||
|
if (window.confirm('Bist du sicher? Alle Unterpunkte dieses Asts werden ebenfalls gelöscht.')) {
|
||||||
|
onDeleteBlockRef.current(block.id, true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onDeleteBlockRef.current(block.id, false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer"
|
||||||
title="Block löschen"
|
title="Block löschen"
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { DocumentBlock } from '../../lib/types';
|
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||||
import { OutlinerBlock } from './OutlinerBlock';
|
import { OutlinerBlock } from './OutlinerBlock';
|
||||||
|
|
||||||
export interface OutlinerTreeProps {
|
export interface OutlinerTreeProps {
|
||||||
@@ -169,8 +169,7 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
const idx = blocks.findIndex((b) => b.id === afterBlockId);
|
const idx = blocks.findIndex((b) => b.id === afterBlockId);
|
||||||
const currentBlock = idx >= 0 ? blocks[idx] : null;
|
const currentBlock = idx >= 0 ? blocks[idx] : null;
|
||||||
const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0;
|
const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0;
|
||||||
const inheritedType =
|
const inheritedType: BlockType = 'text';
|
||||||
currentBlock && currentBlock.type === 'todo' ? 'todo' : 'text';
|
|
||||||
|
|
||||||
const newBlock: DocumentBlock = {
|
const newBlock: DocumentBlock = {
|
||||||
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
||||||
@@ -191,7 +190,7 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleDeleteBlock = useCallback(
|
const handleDeleteBlock = useCallback(
|
||||||
(blockId: string) => {
|
(blockId: string, deleteSubtree = false) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||||
if (idx === -1) return;
|
if (idx === -1) return;
|
||||||
@@ -216,9 +215,25 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
if (targetFocusBlock) {
|
if (targetFocusBlock) {
|
||||||
focusBlockInput(targetFocusBlock.id, 'end');
|
focusBlockInput(targetFocusBlock.id, 'end');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (deleteSubtree) {
|
||||||
|
const rootLevel = blocks[idx].level || 0;
|
||||||
|
const idsToDelete = [blockId];
|
||||||
|
for (let j = idx + 1; j < blocks.length; j++) {
|
||||||
|
if ((blocks[j].level || 0) > rootLevel) {
|
||||||
|
idsToDelete.push(blocks[j].id);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of idsToDelete) {
|
||||||
|
onDeleteBlock(id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
onDeleteBlock(blockId);
|
onDeleteBlock(blockId);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[blocks, canEdit, focusBlockInput, onDeleteBlock, onUpdateBlock]
|
[blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleIndentBlock = useCallback(
|
const handleIndentBlock = useCallback(
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
|
|||||||
interface WorkspaceViewProps {
|
interface WorkspaceViewProps {
|
||||||
currentUser?: User | null;
|
currentUser?: User | null;
|
||||||
initialDocId?: string | null;
|
initialDocId?: string | null;
|
||||||
|
initialBlockId?: string | null;
|
||||||
onStatsChange: () => void;
|
onStatsChange: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ interface FolderTreeItemProps {
|
|||||||
handleFolderDragStart: (e: React.DragEvent, path: string) => void;
|
handleFolderDragStart: (e: React.DragEvent, path: string) => void;
|
||||||
handleDocDragStart: (e: React.DragEvent, id: string) => void;
|
handleDocDragStart: (e: React.DragEvent, id: string) => void;
|
||||||
handleCreateDocument: (folderPath?: string) => void;
|
handleCreateDocument: (folderPath?: string) => void;
|
||||||
|
handleOpenNewFolder: (parentPath?: string, e?: React.MouseEvent) => void;
|
||||||
handleShareFolderClick: (folderPath: string, existingSf?: SharedFolder) => void;
|
handleShareFolderClick: (folderPath: string, existingSf?: SharedFolder) => void;
|
||||||
handleDeleteFolder: (folderPath: string) => void;
|
handleDeleteFolder: (folderPath: string) => void;
|
||||||
onSelectDoc: (id: string) => void;
|
onSelectDoc: (id: string) => void;
|
||||||
@@ -99,13 +101,14 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
|||||||
handleFolderDragStart,
|
handleFolderDragStart,
|
||||||
handleDocDragStart,
|
handleDocDragStart,
|
||||||
handleCreateDocument,
|
handleCreateDocument,
|
||||||
|
handleOpenNewFolder,
|
||||||
handleShareFolderClick,
|
handleShareFolderClick,
|
||||||
handleDeleteFolder,
|
handleDeleteFolder,
|
||||||
onSelectDoc,
|
onSelectDoc,
|
||||||
}) => {
|
}) => {
|
||||||
const isExpanded = Boolean(expandedFolders[node.fullPath]);
|
|
||||||
const isDragOver = dragOverFolder === node.fullPath;
|
|
||||||
const hasContent = node.subfolders.length > 0 || node.docs.length > 0;
|
const hasContent = node.subfolders.length > 0 || node.docs.length > 0;
|
||||||
|
const isExpanded = Boolean(hasContent && expandedFolders[node.fullPath]);
|
||||||
|
const isDragOver = dragOverFolder === node.fullPath;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
@@ -132,6 +135,7 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
hasContent &&
|
||||||
setExpandedFolders((prev) => ({
|
setExpandedFolders((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[node.fullPath]: !prev[node.fullPath],
|
[node.fullPath]: !prev[node.fullPath],
|
||||||
@@ -145,9 +149,7 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<ChevronRight className="w-3.5 h-3.5 text-faint shrink-0" />
|
<ChevronRight className="w-3.5 h-3.5 text-faint shrink-0" />
|
||||||
)
|
)
|
||||||
) : (
|
) : null}
|
||||||
<span className="w-3.5 shrink-0" />
|
|
||||||
)}
|
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<FolderOpen className="w-3.5 h-3.5 text-accent shrink-0" />
|
<FolderOpen className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
@@ -157,9 +159,6 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
|||||||
{node.sharedFolderObj && (
|
{node.sharedFolderObj && (
|
||||||
<Users className="w-3 h-3 text-status-ok shrink-0" />
|
<Users className="w-3 h-3 text-status-ok shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] text-faint ml-auto pr-1">
|
|
||||||
{node.docs.length}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity shrink-0">
|
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity shrink-0">
|
||||||
@@ -169,18 +168,29 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleCreateDocument(node.fullPath);
|
handleCreateDocument(node.fullPath);
|
||||||
}}
|
}}
|
||||||
className="p-1 h-6 w-6 flex items-center justify-center rounded-btn hover:bg-canvas text-muted hover:text-main cursor-pointer"
|
className="p-1 h-6 w-6 flex items-center justify-center text-muted hover:text-main transition-colors cursor-pointer"
|
||||||
title="Notiz in diesem Ordner erstellen"
|
title="Notiz in diesem Ordner erstellen"
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3" />
|
<Plus className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleOpenNewFolder(node.fullPath, e);
|
||||||
|
}}
|
||||||
|
className="p-1 h-6 w-6 flex items-center justify-center text-muted hover:text-main transition-colors cursor-pointer"
|
||||||
|
title="Neuen Unterordner erstellen"
|
||||||
|
>
|
||||||
|
<FolderPlus className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleShareFolderClick(node.fullPath, node.sharedFolderObj);
|
handleShareFolderClick(node.fullPath, node.sharedFolderObj);
|
||||||
}}
|
}}
|
||||||
className={`p-1 h-6 w-6 flex items-center justify-center rounded-btn hover:bg-canvas cursor-pointer ${
|
className={`p-1 h-6 w-6 flex items-center justify-center transition-colors cursor-pointer ${
|
||||||
node.sharedFolderObj ? 'text-accent' : 'text-faint hover:text-accent'
|
node.sharedFolderObj ? 'text-accent' : 'text-faint hover:text-accent'
|
||||||
}`}
|
}`}
|
||||||
title="Ordner teilen und Berechtigungen"
|
title="Ordner teilen und Berechtigungen"
|
||||||
@@ -193,7 +203,7 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleDeleteFolder(node.fullPath);
|
handleDeleteFolder(node.fullPath);
|
||||||
}}
|
}}
|
||||||
className="p-1 h-6 w-6 flex items-center justify-center rounded-btn hover:bg-status-err/20 text-faint hover:text-status-err cursor-pointer"
|
className="p-1 h-6 w-6 flex items-center justify-center text-faint hover:text-status-err transition-colors cursor-pointer"
|
||||||
title="Ordner löschen"
|
title="Ordner löschen"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
@@ -219,6 +229,7 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
|||||||
handleFolderDragStart={handleFolderDragStart}
|
handleFolderDragStart={handleFolderDragStart}
|
||||||
handleDocDragStart={handleDocDragStart}
|
handleDocDragStart={handleDocDragStart}
|
||||||
handleCreateDocument={handleCreateDocument}
|
handleCreateDocument={handleCreateDocument}
|
||||||
|
handleOpenNewFolder={handleOpenNewFolder}
|
||||||
handleShareFolderClick={handleShareFolderClick}
|
handleShareFolderClick={handleShareFolderClick}
|
||||||
handleDeleteFolder={handleDeleteFolder}
|
handleDeleteFolder={handleDeleteFolder}
|
||||||
onSelectDoc={onSelectDoc}
|
onSelectDoc={onSelectDoc}
|
||||||
@@ -235,7 +246,7 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
|||||||
style={{ paddingLeft: `${(depth + 1) * 12 + 16}px` }}
|
style={{ paddingLeft: `${(depth + 1) * 12 + 16}px` }}
|
||||||
className={`w-full flex items-center gap-2 pr-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
|
className={`w-full flex items-center gap-2 pr-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
|
||||||
selectedDocId === d.id && subView === 'editor'
|
selectedDocId === d.id && subView === 'editor'
|
||||||
? 'bg-panel border-accent text-main font-semibold shadow-xs'
|
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
||||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -261,6 +272,7 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
|||||||
export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||||
currentUser,
|
currentUser,
|
||||||
initialDocId,
|
initialDocId,
|
||||||
|
initialBlockId,
|
||||||
onStatsChange,
|
onStatsChange,
|
||||||
}) => {
|
}) => {
|
||||||
const [docs, setDocs] = useState<Document[]>([]);
|
const [docs, setDocs] = useState<Document[]>([]);
|
||||||
@@ -291,7 +303,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}, [docs, serverTagsSummary]);
|
}, [docs, serverTagsSummary]);
|
||||||
|
|
||||||
const [selectedDocId, setSelectedDocId] = useState<string | null>(initialDocId || null);
|
const [selectedDocId, setSelectedDocId] = useState<string | null>(initialDocId || null);
|
||||||
const [targetBlockId, setTargetBlockId] = useState<string | null>(null);
|
const [targetBlockId, setTargetBlockId] = useState<string | null>(initialBlockId || null);
|
||||||
const [newlyCreatedDocId, setNewlyCreatedDocId] = useState<string | null>(null);
|
const [newlyCreatedDocId, setNewlyCreatedDocId] = useState<string | null>(null);
|
||||||
const [subView, setSubView] = useState<WorkspaceSubView>('editor');
|
const [subView, setSubView] = useState<WorkspaceSubView>('editor');
|
||||||
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||||
@@ -301,10 +313,40 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const [tagSortBy, setTagSortBy] = useState<'count' | 'name'>('count');
|
const [tagSortBy, setTagSortBy] = useState<'count' | 'name'>('count');
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Record<string, boolean>>({});
|
const [expandedFolders, setExpandedFolders] = useState<Record<string, boolean>>(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('mindsift:workspace_expanded_folders');
|
||||||
|
return saved ? JSON.parse(saved) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('mindsift:workspace_expanded_folders', JSON.stringify(expandedFolders));
|
||||||
|
} catch {}
|
||||||
|
}, [expandedFolders]);
|
||||||
|
|
||||||
const [showNewFolderModal, setShowNewFolderModal] = useState<boolean>(false);
|
const [showNewFolderModal, setShowNewFolderModal] = useState<boolean>(false);
|
||||||
|
const [newFolderCoords, setNewFolderCoords] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const [newFolderParentPath, setNewFolderParentPath] = useState<string>('');
|
||||||
const [newFolderName, setNewFolderName] = useState<string>('');
|
const [newFolderName, setNewFolderName] = useState<string>('');
|
||||||
const [newFolderIsShared, setNewFolderIsShared] = useState<boolean>(false);
|
const [newFolderIsShared, setNewFolderIsShared] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const handleOpenNewFolder = (parentPath = '', e?: React.MouseEvent) => {
|
||||||
|
setNewFolderParentPath(parentPath);
|
||||||
|
setNewFolderName('');
|
||||||
|
if (e) {
|
||||||
|
setNewFolderCoords({
|
||||||
|
x: Math.min(e.clientX, window.innerWidth - 270),
|
||||||
|
y: Math.min(e.clientY, window.innerHeight - 130),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setNewFolderCoords({ x: 100, y: 150 });
|
||||||
|
}
|
||||||
|
setShowNewFolderModal(true);
|
||||||
|
};
|
||||||
const [mobileShowDetail, setMobileShowDetail] = useState<boolean>(Boolean(initialDocId));
|
const [mobileShowDetail, setMobileShowDetail] = useState<boolean>(Boolean(initialDocId));
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState<boolean>(() => {
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState<boolean>(() => {
|
||||||
try {
|
try {
|
||||||
@@ -345,8 +387,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
// Rollup drilldown & View Mode
|
// Rollup drilldown & View Mode
|
||||||
const [todoFolderDrilldown, setTodoFolderDrilldown] = useState<string | null>(null);
|
const [todoFolderDrilldown, setTodoFolderDrilldown] = useState<string | null>(null);
|
||||||
const [todoGrouping, setTodoGrouping] = useState<'folder_note' | 'note' | 'folder'>(() => {
|
const [todoGrouping, setTodoGrouping] = useState<'grouped' | 'list'>(() => {
|
||||||
return (localStorage.getItem('mindsift_todo_grouping') as any) || 'folder_note';
|
const saved = localStorage.getItem('mindsift_todo_grouping');
|
||||||
|
return saved === 'list' ? 'list' : 'grouped';
|
||||||
});
|
});
|
||||||
const [collapsedTodoFolders, setCollapsedTodoFolders] = useState<Set<string>>(new Set());
|
const [collapsedTodoFolders, setCollapsedTodoFolders] = useState<Set<string>>(new Set());
|
||||||
const [collapsedTodoNotes, setCollapsedTodoNotes] = useState<Set<string>>(new Set());
|
const [collapsedTodoNotes, setCollapsedTodoNotes] = useState<Set<string>>(new Set());
|
||||||
@@ -536,14 +579,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
const handleCreateFolderSubmit = async (e: React.FormEvent) => {
|
const handleCreateFolderSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const clean = newFolderName.trim().replace(/^\/+|\/+$/g, '');
|
const cleanName = newFolderName.trim().replace(/^\/+|\/+$/g, '');
|
||||||
if (!clean) return;
|
if (!cleanName) return;
|
||||||
|
const cleanPath = newFolderParentPath ? `${newFolderParentPath}/${cleanName}` : cleanName;
|
||||||
try {
|
try {
|
||||||
if (newFolderIsShared) {
|
const res = await api.createFolder(cleanPath);
|
||||||
const sf = await api.createSharedFolder({ name: clean, mountPath: clean });
|
|
||||||
setSharedFolders((prev) => [...prev, sf]);
|
|
||||||
}
|
|
||||||
const res = await api.createFolder(clean);
|
|
||||||
setFolders((prev) => (prev.includes(res.path) ? prev : [...prev, res.path].sort()));
|
setFolders((prev) => (prev.includes(res.path) ? prev : [...prev, res.path].sort()));
|
||||||
const parts = res.path.split('/');
|
const parts = res.path.split('/');
|
||||||
let acc = '';
|
let acc = '';
|
||||||
@@ -554,10 +594,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}
|
}
|
||||||
setExpandedFolders((prev) => ({ ...prev, ...expansions }));
|
setExpandedFolders((prev) => ({ ...prev, ...expansions }));
|
||||||
setNewFolderName('');
|
setNewFolderName('');
|
||||||
setNewFolderIsShared(false);
|
setNewFolderParentPath('');
|
||||||
setShowNewFolderModal(false);
|
setShowNewFolderModal(false);
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
// ignore
|
alert(err.message || 'Fehler beim Erstellen des Ordners');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1208,40 +1248,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : null}
|
||||||
/* Standalone Realtime Status Indicator for Todos / Hashtags */
|
|
||||||
(() => {
|
|
||||||
const isConnected = realtimeConnected;
|
|
||||||
const isSSE = realtimeTransport === 'sse';
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`h-8 w-8 flex items-center justify-center rounded-btn border transition-colors ${
|
|
||||||
isConnected
|
|
||||||
? isSSE
|
|
||||||
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
|
|
||||||
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
|
|
||||||
: 'bg-canvas border-border text-muted'
|
|
||||||
}`}
|
|
||||||
title={
|
|
||||||
isConnected
|
|
||||||
? isSSE
|
|
||||||
? 'Synchronisiert (Server-Sent Events / HTTPS)'
|
|
||||||
: 'Synchronisiert (WebSocket online)'
|
|
||||||
: 'Offline'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Radio
|
|
||||||
className={`w-3.5 h-3.5 ${isConnected ? 'animate-pulse' : ''}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Workspace Split Pane */}
|
{/* Main Workspace Split Pane */}
|
||||||
<div className="flex h-[calc(100vh-7.5rem)] bg-canvas rounded-card border border-border overflow-hidden shadow-panel">
|
<div className="flex h-[calc(100vh-7.5rem)] bg-canvas rounded-card border border-border overflow-hidden">
|
||||||
{/* LEFT MASTER SIDEBAR */}
|
{/* LEFT MASTER SIDEBAR */}
|
||||||
<aside
|
<aside
|
||||||
className={`${
|
className={`${
|
||||||
@@ -1250,7 +1262,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
: mobileShowDetail
|
: mobileShowDetail
|
||||||
? 'hidden lg:flex'
|
? 'hidden lg:flex'
|
||||||
: 'flex'
|
: 'flex'
|
||||||
} w-full lg:w-72 xl:w-80 shrink-0 flex-col bg-canvas border-r border-border transition-all duration-200`}
|
} w-full lg:w-72 xl:w-80 shrink-0 flex-col bg-panel border-r border-border transition-all duration-200`}
|
||||||
>
|
>
|
||||||
{/* Sidebar Header & Actions */}
|
{/* Sidebar Header & Actions */}
|
||||||
<div className="p-3 border-b border-border space-y-2">
|
<div className="p-3 border-b border-border space-y-2">
|
||||||
@@ -1261,7 +1273,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowNewFolderModal(true)}
|
onClick={(e) => handleOpenNewFolder('', e)}
|
||||||
className="btn-action p-1 text-muted hover:text-main"
|
className="btn-action p-1 text-muted hover:text-main"
|
||||||
title="Neuer Ordner"
|
title="Neuer Ordner"
|
||||||
>
|
>
|
||||||
@@ -1303,23 +1315,19 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
{/* Scrollable Navigation Tree */}
|
{/* Scrollable Navigation Tree */}
|
||||||
<div className="flex-1 overflow-y-auto p-2 space-y-3">
|
<div className="flex-1 overflow-y-auto p-2 space-y-3">
|
||||||
{/* Drop target for moving docs or folders to root */}
|
{/* Top drop target for moving to root */}
|
||||||
{(draggingDocId || draggingFolderPath) && (
|
{(draggingDocId || draggingFolderPath) && (
|
||||||
<div
|
<div
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDragOverFolder('__root__');
|
setDragOverFolder('__root_top__');
|
||||||
}}
|
}}
|
||||||
onDragLeave={() => setDragOverFolder(null)}
|
onDragLeave={() => setDragOverFolder(null)}
|
||||||
onDrop={(e) => handleDropOnFolder('', e)}
|
onDrop={(e) => handleDropOnFolder('', e)}
|
||||||
className={`p-2 border-2 border-dashed rounded-btn text-center text-xs transition-colors ${
|
className={`h-2.5 -mx-1 my-0.5 rounded transition-all ${
|
||||||
dragOverFolder === '__root__'
|
dragOverFolder === '__root_top__' ? 'bg-accent/40 ring-1 ring-accent h-4' : 'opacity-0'
|
||||||
? 'border-accent bg-accent/20 text-main font-semibold'
|
|
||||||
: 'border-border text-muted bg-canvas'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
/>
|
||||||
📁 In Hauptebene ablegen
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pinned Notes */}
|
{/* Pinned Notes */}
|
||||||
@@ -1342,7 +1350,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}}
|
}}
|
||||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
|
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
|
||||||
selectedDocId === d.id && subView === 'editor'
|
selectedDocId === d.id && subView === 'editor'
|
||||||
? 'bg-panel border-accent text-main font-semibold shadow-xs'
|
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
||||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -1376,6 +1384,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
handleFolderDragStart={handleFolderDragStart}
|
handleFolderDragStart={handleFolderDragStart}
|
||||||
handleDocDragStart={handleDocDragStart}
|
handleDocDragStart={handleDocDragStart}
|
||||||
handleCreateDocument={handleCreateDocument}
|
handleCreateDocument={handleCreateDocument}
|
||||||
|
handleOpenNewFolder={handleOpenNewFolder}
|
||||||
handleShareFolderClick={handleShareFolderClick}
|
handleShareFolderClick={handleShareFolderClick}
|
||||||
handleDeleteFolder={handleDeleteFolder}
|
handleDeleteFolder={handleDeleteFolder}
|
||||||
onSelectDoc={(id) => {
|
onSelectDoc={(id) => {
|
||||||
@@ -1414,7 +1423,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}}
|
}}
|
||||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
|
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
|
||||||
selectedDocId === d.id && subView === 'editor'
|
selectedDocId === d.id && subView === 'editor'
|
||||||
? 'bg-panel border-accent text-main font-semibold shadow-xs'
|
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
||||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -1429,6 +1438,21 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom drop target for moving to root */}
|
||||||
|
{(draggingDocId || draggingFolderPath) && (
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOverFolder('__root_bottom__');
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragOverFolder(null)}
|
||||||
|
onDrop={(e) => handleDropOnFolder('', e)}
|
||||||
|
className={`min-h-[2.5rem] flex-1 rounded transition-all ${
|
||||||
|
dragOverFolder === '__root_bottom__' ? 'bg-accent/20 ring-1 ring-accent' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tags Explorer */}
|
{/* Tags Explorer */}
|
||||||
{tagsSummary.length > 0 && (
|
{tagsSummary.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
@@ -1586,7 +1610,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<div
|
<div
|
||||||
key={t.tag}
|
key={t.tag}
|
||||||
onClick={() => handleSelectTag(t.tag)}
|
onClick={() => handleSelectTag(t.tag)}
|
||||||
className="p-4 rounded-card border border-border bg-canvas hover:border-accent hover:shadow-panel transition-all cursor-pointer group flex flex-col justify-between space-y-3"
|
className="p-4 rounded-card border border-border bg-panel hover:border-accent hover:bg-panel-elevated transition-all cursor-pointer group flex flex-col justify-between space-y-3"
|
||||||
>
|
>
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
@@ -1595,35 +1619,25 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
#
|
#
|
||||||
</span>
|
</span>
|
||||||
<h3 className="font-bold text-sm text-main group-hover:text-accent transition-colors truncate">
|
<h3 className="font-bold text-sm text-main group-hover:text-accent transition-colors truncate">
|
||||||
#{t.tag}
|
{t.tag.replace(/^#/, '')}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<span className="badge-neutral font-mono text-[10px] shrink-0">
|
<span className="badge-neutral font-mono text-[10px] shrink-0">
|
||||||
{t.count} {t.count === 1 ? 'Treffer' : 'Treffer'}
|
{t.count} Treffer
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{meta && meta.docs.length > 0 && (
|
{meta && meta.docs.length > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-[10px] font-semibold text-faint uppercase tracking-wider block">
|
<span className="text-[10px] font-semibold text-faint uppercase tracking-wider block">
|
||||||
In {meta.docs.length} {meta.docs.length === 1 ? 'Notiz' : 'Notizen'}:
|
Aktuellste Notiz:
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div
|
||||||
{meta.docs.slice(0, 3).map((d) => (
|
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"
|
||||||
<span
|
title={meta.docs[0].title}
|
||||||
key={d.id}
|
|
||||||
className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-btn bg-panel-elevated text-muted truncate max-w-[140px]"
|
|
||||||
title={d.title}
|
|
||||||
>
|
>
|
||||||
<span>{d.icon || '📝'}</span>
|
<span>{meta.docs[0].icon || '📝'}</span>
|
||||||
<span className="truncate">{d.title}</span>
|
<span className="truncate">{meta.docs[0].title || 'Unbenannte Notiz'}</span>
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{meta.docs.length > 3 && (
|
|
||||||
<span className="text-[10px] text-faint self-center">
|
|
||||||
+{meta.docs.length - 3}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1631,13 +1645,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
<div className="flex items-center justify-between pt-2.5 border-t border-border/50 text-[11px]">
|
<div className="flex items-center justify-between pt-2.5 border-t border-border/50 text-[11px]">
|
||||||
<div>
|
<div>
|
||||||
{meta && meta.openTodosCount > 0 ? (
|
{meta && meta.openTodosCount > 0 && (
|
||||||
<span className="text-status-warn text-[10px] flex items-center gap-1 font-medium">
|
<span className="text-status-warn text-[10px] flex items-center gap-1 font-medium">
|
||||||
<Check className="w-2.5 h-2.5" />
|
<Check className="w-2.5 h-2.5" />
|
||||||
{meta.openTodosCount} offene To-Dos
|
{meta.openTodosCount} offene To-Dos
|
||||||
</span>
|
</span>
|
||||||
) : (
|
|
||||||
<span className="text-faint text-[10px]">Blöcke & Childs anzeigen</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-accent text-xs font-semibold group-hover:translate-x-0.5 transition-transform flex items-center gap-0.5">
|
<span className="text-accent text-xs font-semibold group-hover:translate-x-0.5 transition-transform flex items-center gap-0.5">
|
||||||
@@ -1863,8 +1875,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<div className="w-full space-y-4">
|
<div className="w-full space-y-4">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between border-b border-border pb-3 gap-2">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between border-b border-border pb-3 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-main">
|
<h2 className="text-base sm:text-lg font-bold text-main">
|
||||||
Globaler To-Do Rollup ({totalOpenTodos} offen)
|
To-Dos aus Notizen ({totalOpenTodos} offen)
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
Alle Aufgaben aus deinen Notizen im gesamten Workspace
|
Alle Aufgaben aus deinen Notizen im gesamten Workspace
|
||||||
@@ -1872,60 +1884,37 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{/* View Grouping Selector */}
|
{/* View Grouping Selector (matching hashtag switcher style) */}
|
||||||
<div className="flex items-center rounded-btn bg-panel p-0.5 border border-border shadow-xs">
|
<div className="flex items-center bg-canvas border border-border rounded-btn p-0.5 text-[11px]">
|
||||||
<span className="text-[10px] uppercase font-bold text-faint px-1.5 hidden sm:inline">
|
|
||||||
Ansicht:
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTodoGrouping('folder_note');
|
setTodoGrouping('grouped');
|
||||||
localStorage.setItem('mindsift_todo_grouping', 'folder_note');
|
localStorage.setItem('mindsift_todo_grouping', 'grouped');
|
||||||
}}
|
}}
|
||||||
className={`px-2 py-1 text-[11px] font-semibold rounded-btn transition-colors cursor-pointer flex items-center gap-1 ${
|
className={`px-2.5 py-1 rounded-btn font-semibold transition-colors cursor-pointer ${
|
||||||
todoGrouping === 'folder_note'
|
todoGrouping === 'grouped'
|
||||||
? 'bg-panel-elevated text-main shadow-xs'
|
? 'bg-panel-elevated text-main shadow-xs'
|
||||||
: 'text-muted hover:text-main'
|
: 'text-muted hover:text-main'
|
||||||
}`}
|
}`}
|
||||||
title="Erst nach Ordner, dann nach Notiz gruppieren"
|
title="Sortiert nach Ordner und Notiz"
|
||||||
>
|
>
|
||||||
<Folder className="w-3 h-3 text-accent" />
|
Gruppiert
|
||||||
<span>+</span>
|
|
||||||
<FileText className="w-3 h-3 text-accent" />
|
|
||||||
<span>Ordner & Notiz</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTodoGrouping('note');
|
setTodoGrouping('list');
|
||||||
localStorage.setItem('mindsift_todo_grouping', 'note');
|
localStorage.setItem('mindsift_todo_grouping', 'list');
|
||||||
}}
|
}}
|
||||||
className={`px-2 py-1 text-[11px] font-semibold rounded-btn transition-colors cursor-pointer flex items-center gap-1 ${
|
className={`px-2.5 py-1 rounded-btn font-semibold transition-colors cursor-pointer ${
|
||||||
todoGrouping === 'note'
|
todoGrouping === 'list'
|
||||||
? 'bg-panel-elevated text-main shadow-xs'
|
? 'bg-panel-elevated text-main shadow-xs'
|
||||||
: 'text-muted hover:text-main'
|
: 'text-muted hover:text-main'
|
||||||
}`}
|
}`}
|
||||||
title="Direkt nach Notiz gruppieren"
|
title="Alle To-Dos als einfache Liste"
|
||||||
>
|
>
|
||||||
<FileText className="w-3 h-3 text-accent" />
|
Liste
|
||||||
<span>Notiz</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setTodoGrouping('folder');
|
|
||||||
localStorage.setItem('mindsift_todo_grouping', 'folder');
|
|
||||||
}}
|
|
||||||
className={`px-2 py-1 text-[11px] font-semibold rounded-btn transition-colors cursor-pointer flex items-center gap-1 ${
|
|
||||||
todoGrouping === 'folder'
|
|
||||||
? 'bg-panel-elevated text-main shadow-xs'
|
|
||||||
: 'text-muted hover:text-main'
|
|
||||||
}`}
|
|
||||||
title="Nur nach Ordner gruppieren"
|
|
||||||
>
|
|
||||||
<Folder className="w-3 h-3 text-accent" />
|
|
||||||
<span>Ordner</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1949,69 +1938,20 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
{/* Grouped Folders & To-Dos */}
|
{/* Grouped Folders & To-Dos */}
|
||||||
{allRollupTodos.length === 0 ? (
|
{allRollupTodos.length === 0 ? (
|
||||||
<div className="p-8 text-center text-muted text-xs space-y-1">
|
<div className="p-8 text-center text-muted text-xs space-y-1 border border-border rounded-card bg-panel">
|
||||||
<Sparkles className="w-6 h-6 text-accent mx-auto mb-1" />
|
<Sparkles className="w-6 h-6 text-accent mx-auto mb-1" />
|
||||||
<p className="font-semibold text-main">Keine To-Dos in Notizen gefunden!</p>
|
<p className="font-semibold text-main">Keine To-Dos in Notizen gefunden!</p>
|
||||||
<p className="text-faint">
|
<p className="text-faint">
|
||||||
Tippe in einem beliebigen Dokument <code>/todo</code> oder <code>[] </code> ein.
|
Tippe in einem beliebigen Dokument <code>/todo</code> oder <code>[] </code> ein.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : todoGrouping === 'note' ? (
|
) : todoGrouping === 'list' ? (
|
||||||
/* Grouped by Note directly */
|
/* Flat list of all todos across notes */
|
||||||
<div className="space-y-3">
|
<div className="border border-border rounded-card bg-panel p-2 space-y-1">
|
||||||
{Array.from(rollupTodosByNote.entries()).map(([docId, { doc, items }]) => {
|
{allRollupTodos.map((item) => (
|
||||||
const isCollapsed = collapsedTodoNotes.has(docId);
|
|
||||||
const openCount = items.filter((i) => !i.checked).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={docId}
|
|
||||||
className="border border-border rounded-card bg-canvas overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="p-2 bg-canvas border-b border-border flex items-center justify-between">
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
setCollapsedTodoNotes((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(docId)) next.delete(docId);
|
|
||||||
else next.add(docId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 cursor-pointer select-none"
|
|
||||||
>
|
|
||||||
{isCollapsed ? (
|
|
||||||
<ChevronRight className="w-3.5 h-3.5 text-faint" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-3.5 h-3.5 text-accent" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm">{doc.icon || '📝'}</span>
|
|
||||||
<span className="font-bold text-xs text-main">{doc.title}</span>
|
|
||||||
{doc.folderPath && (
|
|
||||||
<span className="badge-neutral text-[10px]">📁 {doc.folderPath}</span>
|
|
||||||
)}
|
|
||||||
<span className="badge-neutral text-[10px] font-mono">
|
|
||||||
{openCount} offen
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleJumpToDocBlock(doc.id)}
|
|
||||||
className="text-[10px] text-accent hover:underline px-1.5 py-0.5 rounded-btn hover:bg-canvas cursor-pointer flex items-center gap-1"
|
|
||||||
title="Zur Notiz springen"
|
|
||||||
>
|
|
||||||
<span>Notiz öffnen</span>
|
|
||||||
<ExternalLink className="w-2.5 h-2.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isCollapsed && (
|
|
||||||
<div className="p-2 space-y-1">
|
|
||||||
{items.map((item) => (
|
|
||||||
<div
|
<div
|
||||||
key={`${item.doc.id}-${item.blockId}`}
|
key={`${item.doc.id}-${item.blockId}`}
|
||||||
className="flex items-center justify-between gap-2 p-1.5 rounded-btn hover:bg-panel-elevated text-xs transition-colors group cursor-pointer"
|
className="flex items-center justify-between gap-2 p-2 rounded-btn hover:bg-panel-elevated text-xs transition-colors group cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<button
|
<button
|
||||||
@@ -2039,6 +1979,18 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
{item.content}
|
{item.content}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<span
|
||||||
|
onClick={() => handleJumpToDocBlock(item.doc.id, item.blockId)}
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-btn bg-panel-elevated text-muted hover:text-main"
|
||||||
|
title={item.doc.title}
|
||||||
|
>
|
||||||
|
<span>{item.doc.icon || '📝'}</span>
|
||||||
|
<span className="truncate max-w-[120px]">{item.doc.title}</span>
|
||||||
|
{item.doc.folderPath && (
|
||||||
|
<span className="text-faint text-[10px]">({item.doc.folderPath})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleJumpToDocBlock(item.doc.id, item.blockId)}
|
onClick={() => handleJumpToDocBlock(item.doc.id, item.blockId)}
|
||||||
@@ -2048,14 +2000,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<ExternalLink className="w-3 h-3" />
|
<ExternalLink className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : todoGrouping === 'folder_note' ? (
|
|
||||||
/* Grouped by Folder -> grouped by Note */
|
/* Grouped by Folder -> grouped by Note */
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{Array.from(rollupTodosByFolder.entries()).map(([folderName, items]) => {
|
{Array.from(rollupTodosByFolder.entries()).map(([folderName, items]) => {
|
||||||
@@ -2073,10 +2021,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={folderName}
|
key={folderName}
|
||||||
className="border border-border rounded-card bg-canvas overflow-hidden"
|
className="border border-border rounded-card bg-panel overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Folder Header */}
|
{/* Folder Header */}
|
||||||
<div className="p-2 bg-canvas border-b border-border flex items-center justify-between">
|
<div className="p-2 bg-panel border-b border-border flex items-center justify-between">
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCollapsedTodoFolders((prev) => {
|
setCollapsedTodoFolders((prev) => {
|
||||||
@@ -2103,7 +2051,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTodoFolderDrilldown(folderName)}
|
onClick={() => setTodoFolderDrilldown(folderName)}
|
||||||
className="text-[10px] text-muted hover:text-main px-1.5 py-0.5 rounded-btn hover:bg-canvas cursor-pointer"
|
className="text-[10px] text-muted hover:text-main px-1.5 py-0.5 rounded-btn hover:bg-panel-elevated cursor-pointer"
|
||||||
title="Diesen Ordner isolieren"
|
title="Diesen Ordner isolieren"
|
||||||
>
|
>
|
||||||
Nur diesen Ordner
|
Nur diesen Ordner
|
||||||
@@ -2119,7 +2067,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={doc.id}
|
key={doc.id}
|
||||||
className="border border-border/60 rounded-btn p-2 bg-panel-elevated/30 space-y-1.5"
|
className="border border-border/60 rounded-btn p-2 bg-panel-elevated/40 space-y-1.5"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between pb-1 border-b border-border/40">
|
<div className="flex items-center justify-between pb-1 border-b border-border/40">
|
||||||
<div
|
<div
|
||||||
@@ -2148,7 +2096,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleJumpToDocBlock(doc.id)}
|
onClick={() => handleJumpToDocBlock(doc.id)}
|
||||||
className="text-[10px] text-accent hover:underline px-1.5 py-0.5 rounded-btn hover:bg-canvas cursor-pointer flex items-center gap-1"
|
className="text-[10px] text-accent hover:underline px-1.5 py-0.5 rounded-btn hover:bg-panel cursor-pointer flex items-center gap-1"
|
||||||
title="Notiz öffnen"
|
title="Notiz öffnen"
|
||||||
>
|
>
|
||||||
<span>Notiz öffnen</span>
|
<span>Notiz öffnen</span>
|
||||||
@@ -2161,7 +2109,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
{docItems.map((item) => (
|
{docItems.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={`${item.doc.id}-${item.blockId}`}
|
key={`${item.doc.id}-${item.blockId}`}
|
||||||
className="flex items-center justify-between gap-2 p-1 rounded-btn hover:bg-canvas text-xs transition-colors group cursor-pointer"
|
className="flex items-center justify-between gap-2 p-1 rounded-btn hover:bg-panel-elevated text-xs transition-colors group cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<button
|
<button
|
||||||
@@ -2218,105 +2166,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
/* Grouped by Folder (Flat tasks inside folder) */
|
|
||||||
<div className="space-y-3">
|
|
||||||
{Array.from(rollupTodosByFolder.entries()).map(([folderName, items]) => {
|
|
||||||
const isCollapsed = collapsedTodoFolders.has(folderName);
|
|
||||||
const openCount = items.filter((i) => !i.checked).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={folderName}
|
|
||||||
className="border border-border rounded-card bg-canvas overflow-hidden"
|
|
||||||
>
|
|
||||||
{/* Folder Header */}
|
|
||||||
<div className="p-2 bg-canvas border-b border-border flex items-center justify-between">
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
setCollapsedTodoFolders((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(folderName)) next.delete(folderName);
|
|
||||||
else next.add(folderName);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 cursor-pointer select-none"
|
|
||||||
>
|
|
||||||
{isCollapsed ? (
|
|
||||||
<ChevronRight className="w-3.5 h-3.5 text-faint" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-3.5 h-3.5 text-accent" />
|
|
||||||
)}
|
|
||||||
<Folder className="w-3.5 h-3.5 text-accent" />
|
|
||||||
<span className="font-bold text-xs text-main">{folderName}</span>
|
|
||||||
<span className="badge-neutral text-[10px] font-mono">
|
|
||||||
{openCount} offen
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTodoFolderDrilldown(folderName)}
|
|
||||||
className="text-[10px] text-muted hover:text-main px-1.5 py-0.5 rounded-btn hover:bg-canvas cursor-pointer"
|
|
||||||
title="Diesen Ordner isolieren"
|
|
||||||
>
|
|
||||||
Nur diesen Ordner
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Folder Tasks */}
|
|
||||||
{!isCollapsed && (
|
|
||||||
<div className="p-2 space-y-1">
|
|
||||||
{items.map((item) => (
|
|
||||||
<div
|
|
||||||
key={`${item.doc.id}-${item.blockId}`}
|
|
||||||
className="flex items-center justify-between gap-2 p-1.5 rounded-btn hover:bg-panel-elevated text-xs transition-colors group cursor-pointer"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleToggleRollupTodo(item.doc, item.blockId, item.checked);
|
|
||||||
}}
|
|
||||||
className={`w-3.5 h-3.5 checkbox-square flex items-center justify-center transition-colors border cursor-pointer ${
|
|
||||||
item.checked
|
|
||||||
? 'bg-status-ok border-status-ok text-on-accent'
|
|
||||||
: 'border-strong hover:border-accent text-transparent'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Check className="w-2.5 h-2.5 stroke-[3]" />
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
onClick={() => handleJumpToDocBlock(item.doc.id, item.blockId)}
|
|
||||||
className={`truncate flex-1 ${
|
|
||||||
item.checked
|
|
||||||
? 'line-through text-faint'
|
|
||||||
: 'text-main font-medium group-hover:text-accent'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.content}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleJumpToDocBlock(item.doc.id, item.blockId)}
|
|
||||||
className="text-[11px] text-accent hover:underline flex items-center gap-1 shrink-0 cursor-pointer"
|
|
||||||
title="Zu dieser Zeile springen"
|
|
||||||
>
|
|
||||||
<span>{item.doc.icon || '📝'}</span>
|
|
||||||
<span className="truncate max-w-[120px]">{item.doc.title}</span>
|
|
||||||
<ExternalLink className="w-2.5 h-2.5 opacity-60" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2355,55 +2204,45 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* New Folder Modal */}
|
{/* New Folder Popover (small & positioned near click, asks only for name & OK) */}
|
||||||
{showNewFolderModal && (
|
{showNewFolderModal && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-overlay backdrop-blur-sm p-4">
|
<div
|
||||||
|
className="fixed inset-0 z-50 bg-black/20"
|
||||||
|
onClick={() => setShowNewFolderModal(false)}
|
||||||
|
>
|
||||||
<form
|
<form
|
||||||
onSubmit={handleCreateFolderSubmit}
|
onSubmit={handleCreateFolderSubmit}
|
||||||
className="w-full max-w-md panel-surface shadow-panel border border-border rounded-card p-4 space-y-3"
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={
|
||||||
|
newFolderCoords
|
||||||
|
? { top: `${newFolderCoords.y}px`, left: `${newFolderCoords.x}px` }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className="fixed w-64 bg-panel border border-border rounded-card p-3 shadow-lg space-y-2.5 z-50 text-xs"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="font-semibold text-main text-xs flex items-center justify-between">
|
||||||
<h3 className="text-sm font-bold text-main">
|
<span className="truncate">{newFolderParentPath ? `Unterordner in "${newFolderParentPath}"` : 'Neuer Ordner'}</span>
|
||||||
Neuen Ordner anlegen
|
|
||||||
</h3>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowNewFolderModal(false)}
|
onClick={() => setShowNewFolderModal(false)}
|
||||||
className="p-1 rounded-btn text-faint hover:text-main"
|
className="text-faint hover:text-main cursor-pointer"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-[11px] font-medium text-muted">
|
|
||||||
Ordnerpfad (z.B. <code>Projekte/2026</code>)
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
autoFocus
|
autoFocus
|
||||||
value={newFolderName}
|
value={newFolderName}
|
||||||
onChange={(e) => setNewFolderName(e.target.value)}
|
onChange={(e) => setNewFolderName(e.target.value)}
|
||||||
placeholder="Projekte"
|
placeholder="Ordnername"
|
||||||
className="input-field w-full px-2.5 py-1.5 text-xs"
|
className="input-field w-full px-2 py-1 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
<div className="flex justify-end gap-1.5 pt-1">
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-xs text-muted cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={newFolderIsShared}
|
|
||||||
onChange={(e) => setNewFolderIsShared(e.target.checked)}
|
|
||||||
className="checkbox-square"
|
|
||||||
/>
|
|
||||||
<span>Als geteilten Team-Ordner freigeben</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-1">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowNewFolderModal(false)}
|
onClick={() => setShowNewFolderModal(false)}
|
||||||
className="btn-action px-3 py-1 text-xs"
|
className="btn-action px-2.5 py-1 text-xs"
|
||||||
>
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
@@ -2411,14 +2250,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
type="submit"
|
type="submit"
|
||||||
className="btn-primary px-3 py-1 text-xs font-semibold"
|
className="btn-primary px-3 py-1 text-xs font-semibold"
|
||||||
>
|
>
|
||||||
Erstellen
|
OK
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Unified Sharing Modal */}
|
{/* Unified Sharing Modal (Strictly Read-Only for Docs and Folders) */}
|
||||||
{(shareModalDoc || shareModalFolder) && (
|
{(shareModalDoc || shareModalFolder) && (
|
||||||
<UnifiedShareModal
|
<UnifiedShareModal
|
||||||
isOpen={true}
|
isOpen={true}
|
||||||
@@ -2437,9 +2276,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
shares={unifiedShares}
|
shares={unifiedShares}
|
||||||
teams={teams}
|
teams={teams}
|
||||||
collaborators={collaborators}
|
collaborators={collaborators}
|
||||||
allowRoleSelection={!shareModalDoc}
|
allowRoleSelection={false}
|
||||||
onAddShare={async (targetType, targetId, permission) => {
|
fixedPermission="viewer"
|
||||||
const perm = (!shareModalDoc && permission === 'editor') ? 'write' : 'read';
|
onAddShare={async (targetType, targetId) => {
|
||||||
if (shareModalDoc) {
|
if (shareModalDoc) {
|
||||||
await api.createDocShare(
|
await api.createDocShare(
|
||||||
shareModalDoc.id,
|
shareModalDoc.id,
|
||||||
@@ -2453,7 +2292,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
await api.createFolderShare(shareModalFolder.id, {
|
await api.createFolderShare(shareModalFolder.id, {
|
||||||
targetType,
|
targetType,
|
||||||
targetId,
|
targetId,
|
||||||
permission: perm,
|
permission: 'read',
|
||||||
});
|
});
|
||||||
const updatedFolder = await api.getSharedFolder(shareModalFolder.id);
|
const updatedFolder = await api.getSharedFolder(shareModalFolder.id);
|
||||||
setShareModalFolder(updatedFolder);
|
setShareModalFolder(updatedFolder);
|
||||||
|
|||||||
@@ -649,6 +649,17 @@ export const api = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async createEnhancedTask(
|
||||||
|
docId: string,
|
||||||
|
blockId: string,
|
||||||
|
payload: { content: string; checked: boolean }
|
||||||
|
): Promise<Thought> {
|
||||||
|
return request<Thought>(`/api/v1/documents/${docId}/blocks/${blockId}/enhanced-task`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// --- DOCUMENT SHARING ---
|
// --- DOCUMENT SHARING ---
|
||||||
async getDocShares(docId: string): Promise<DocShare[]> {
|
async getDocShares(docId: string): Promise<DocShare[]> {
|
||||||
const res = await request<DocShare[]>(`/api/v1/documents/${docId}/shares`);
|
const res = await request<DocShare[]>(`/api/v1/documents/${docId}/shares`);
|
||||||
@@ -796,6 +807,17 @@ export const api = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateListItem(
|
||||||
|
listId: string,
|
||||||
|
itemId: string,
|
||||||
|
payload: { content?: string; category?: string; quantity?: string }
|
||||||
|
): Promise<ListItem> {
|
||||||
|
return request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async deleteListItem(listId: string, itemId: string): Promise<void> {
|
async deleteListItem(listId: string, itemId: string): Promise<void> {
|
||||||
await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
|
await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses inline markdown:
|
||||||
|
* - **bold** or __bold__
|
||||||
|
* - *italic* or _italic_
|
||||||
|
* - ~~strikethrough~~
|
||||||
|
* - `code`
|
||||||
|
* - [label](url)
|
||||||
|
*/
|
||||||
|
export function renderInlineMarkdown(text: string): React.ReactNode[] {
|
||||||
|
if (!text) return [];
|
||||||
|
|
||||||
|
const regex = /(`[^`]+`|\*\*[^*]+\*\*|__[^_]+__|~~[^~]+~~|\*[^*]+\*|_[^_]+_|\[([^\]]+)\]\(([^)]+)\))/g;
|
||||||
|
|
||||||
|
const elements: React.ReactNode[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = regex.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
elements.push(text.slice(lastIndex, match.index));
|
||||||
|
}
|
||||||
|
const token = match[0];
|
||||||
|
const key = `${match.index}-${token}`;
|
||||||
|
|
||||||
|
if (token.startsWith('`') && token.endsWith('`')) {
|
||||||
|
elements.push(
|
||||||
|
<code key={key} className="px-1.5 py-0.5 rounded bg-panel-elevated text-accent font-mono text-[0.9em] border border-border/50">
|
||||||
|
{token.slice(1, -1)}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
(token.startsWith('**') && token.endsWith('**')) ||
|
||||||
|
(token.startsWith('__') && token.endsWith('__'))
|
||||||
|
) {
|
||||||
|
elements.push(
|
||||||
|
<strong key={key} className="font-semibold text-main">
|
||||||
|
{token.slice(2, -2)}
|
||||||
|
</strong>
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
(token.startsWith('*') && token.endsWith('*')) ||
|
||||||
|
(token.startsWith('_') && token.endsWith('_'))
|
||||||
|
) {
|
||||||
|
elements.push(
|
||||||
|
<em key={key} className="italic text-main/90">
|
||||||
|
{token.slice(1, -1)}
|
||||||
|
</em>
|
||||||
|
);
|
||||||
|
} else if (token.startsWith('~~') && token.endsWith('~~')) {
|
||||||
|
elements.push(
|
||||||
|
<del key={key} className="line-through text-muted/80">
|
||||||
|
{token.slice(2, -2)}
|
||||||
|
</del>
|
||||||
|
);
|
||||||
|
} else if (token.startsWith('[') && match[2] && match[3]) {
|
||||||
|
const label = match[2];
|
||||||
|
const url = match[3];
|
||||||
|
elements.push(
|
||||||
|
<a
|
||||||
|
key={key}
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-accent underline hover:opacity-80 transition-opacity"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
elements.push(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = regex.lastIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
elements.push(text.slice(lastIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkdownViewer({ content, className = '' }: { content: string; className?: string }) {
|
||||||
|
if (!content) return null;
|
||||||
|
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const renderedNodes: React.ReactNode[] = [];
|
||||||
|
let inCodeBlock = false;
|
||||||
|
let codeBlockLines: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
|
||||||
|
if (line.trim().startsWith('```')) {
|
||||||
|
if (inCodeBlock) {
|
||||||
|
renderedNodes.push(
|
||||||
|
<pre key={`code-${i}`} className="p-2.5 my-2 rounded bg-panel-elevated text-accent font-mono text-xs overflow-x-auto border border-border/60">
|
||||||
|
<code>{codeBlockLines.join('\n')}</code>
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
codeBlockLines = [];
|
||||||
|
inCodeBlock = false;
|
||||||
|
} else {
|
||||||
|
inCodeBlock = true;
|
||||||
|
codeBlockLines = [];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inCodeBlock) {
|
||||||
|
codeBlockLines.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
if (trimmed.startsWith('### ')) {
|
||||||
|
renderedNodes.push(
|
||||||
|
<h3 key={i} className="text-sm font-bold text-main mt-2 mb-1">
|
||||||
|
{renderInlineMarkdown(trimmed.slice(4))}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
} else if (trimmed.startsWith('## ')) {
|
||||||
|
renderedNodes.push(
|
||||||
|
<h2 key={i} className="text-base font-bold text-main mt-3 mb-1">
|
||||||
|
{renderInlineMarkdown(trimmed.slice(3))}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
} else if (trimmed.startsWith('# ')) {
|
||||||
|
renderedNodes.push(
|
||||||
|
<h1 key={i} className="text-lg font-bold text-main mt-3 mb-1.5">
|
||||||
|
{renderInlineMarkdown(trimmed.slice(2))}
|
||||||
|
</h1>
|
||||||
|
);
|
||||||
|
} else if (trimmed.startsWith('> ')) {
|
||||||
|
renderedNodes.push(
|
||||||
|
<blockquote key={i} className="border-l-2 border-accent pl-2.5 my-1 text-muted italic">
|
||||||
|
{renderInlineMarkdown(trimmed.slice(2))}
|
||||||
|
</blockquote>
|
||||||
|
);
|
||||||
|
} else if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
|
||||||
|
renderedNodes.push(
|
||||||
|
<div key={i} className="flex items-start gap-1.5 my-0.5">
|
||||||
|
<span className="text-accent mt-0.5">•</span>
|
||||||
|
<span className="flex-1 text-main">{renderInlineMarkdown(trimmed.slice(2))}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (/^\d+\.\s/.test(trimmed)) {
|
||||||
|
const matchNum = trimmed.match(/^(\d+)\.\s(.*)$/);
|
||||||
|
renderedNodes.push(
|
||||||
|
<div key={i} className="flex items-start gap-1.5 my-0.5">
|
||||||
|
<span className="text-muted text-[11px] font-mono mt-0.5">{matchNum ? matchNum[1] + '.' : '•'}</span>
|
||||||
|
<span className="flex-1 text-main">{renderInlineMarkdown(matchNum ? matchNum[2] : trimmed)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (trimmed === '') {
|
||||||
|
renderedNodes.push(<div key={i} className="h-2" />);
|
||||||
|
} else {
|
||||||
|
renderedNodes.push(
|
||||||
|
<div key={i} className="my-0.5 text-main leading-relaxed">
|
||||||
|
{renderInlineMarkdown(line)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inCodeBlock && codeBlockLines.length > 0) {
|
||||||
|
renderedNodes.push(
|
||||||
|
<pre key="code-end" className="p-2.5 my-2 rounded bg-panel-elevated text-accent font-mono text-xs overflow-x-auto border border-border/60">
|
||||||
|
<code>{codeBlockLines.join('\n')}</code>
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={`markdown-body text-xs ${className}`}>{renderedNodes}</div>;
|
||||||
|
}
|
||||||
@@ -53,8 +53,10 @@ export interface Thought {
|
|||||||
source: 'manual' | 'voice' | 'webhook';
|
source: 'manual' | 'voice' | 'webhook';
|
||||||
status: ThoughtStatus;
|
status: ThoughtStatus;
|
||||||
isCompleted: boolean;
|
isCompleted: boolean;
|
||||||
convertedType?: 'task' | 'document' | 'list_item';
|
convertedType?: 'task' | 'document' | 'list_item' | 'document_block';
|
||||||
convertedRefId?: string;
|
convertedRefId?: string;
|
||||||
|
docId?: string;
|
||||||
|
blockId?: string;
|
||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
taskListId?: string;
|
taskListId?: string;
|
||||||
kanbanStatus?: string;
|
kanbanStatus?: string;
|
||||||
@@ -159,6 +161,7 @@ export interface DocumentBlock {
|
|||||||
id: string;
|
id: string;
|
||||||
docId?: string;
|
docId?: string;
|
||||||
documentId?: string;
|
documentId?: string;
|
||||||
|
taskId?: string;
|
||||||
type: BlockType;
|
type: BlockType;
|
||||||
content: string;
|
content: string;
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Patch("/api/v1/documents/{id}/blocks", s.handlePatchDocumentBlocks)
|
r.Patch("/api/v1/documents/{id}/blocks", s.handlePatchDocumentBlocks)
|
||||||
r.Put("/api/v1/documents/{id}/move", s.handleMoveDocument)
|
r.Put("/api/v1/documents/{id}/move", s.handleMoveDocument)
|
||||||
r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument)
|
r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument)
|
||||||
|
r.Post("/api/v1/documents/{id}/blocks/{blockId}/enhanced-task", s.handleCreateEnhancedTask)
|
||||||
|
|
||||||
// Document Sharing & Collaboration
|
// Document Sharing & Collaboration
|
||||||
r.Get("/api/v1/documents/{id}/shares", s.handleGetDocShares)
|
r.Get("/api/v1/documents/{id}/shares", s.handleGetDocShares)
|
||||||
@@ -151,6 +152,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Delete("/api/v1/lists/{id}/shares/{shareId}", s.handleDeleteListShare)
|
r.Delete("/api/v1/lists/{id}/shares/{shareId}", s.handleDeleteListShare)
|
||||||
r.Post("/api/v1/lists/{id}/items", s.handleCreateListItem)
|
r.Post("/api/v1/lists/{id}/items", s.handleCreateListItem)
|
||||||
r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem)
|
r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem)
|
||||||
|
r.Put("/api/v1/lists/{id}/items/{itemId}", s.handleUpdateListItem)
|
||||||
r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem)
|
r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem)
|
||||||
r.Post("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
|
r.Post("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
|
||||||
r.Put("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
|
r.Put("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
|
||||||
@@ -828,6 +830,9 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.IsCompleted != nil {
|
||||||
|
s.store.SyncDocBlockAndTask(userID, thoughtID, *req.IsCompleted)
|
||||||
|
}
|
||||||
s.wsHub.BroadcastToAll("TASK_UPDATED", updated)
|
s.wsHub.BroadcastToAll("TASK_UPDATED", updated)
|
||||||
writeJSON(w, http.StatusOK, updated)
|
writeJSON(w, http.StatusOK, updated)
|
||||||
}
|
}
|
||||||
@@ -835,6 +840,7 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
thoughtID := chi.URLParam(r, "id")
|
thoughtID := chi.URLParam(r, "id")
|
||||||
|
s.store.UnlinkTaskFromDocBlock(userID, thoughtID)
|
||||||
if err := s.store.DeleteThought(userID, thoughtID); err != nil {
|
if err := s.store.DeleteThought(userID, thoughtID); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -997,6 +1003,30 @@ func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateEnhancedTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
blockID := chi.URLParam(r, "blockId")
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Checked bool `json:"checked"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
thought, err := s.store.CreateTaskFromDocBlock(userID, docID, blockID, req.Content, req.Checked)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.wsHub.BroadcastToUser(userID, "TASK_CREATED", thought)
|
||||||
|
writeJSON(w, http.StatusCreated, thought)
|
||||||
|
}
|
||||||
|
|
||||||
// --- WORKSPACE FOLDERS ---
|
// --- WORKSPACE FOLDERS ---
|
||||||
|
|
||||||
func (s *Server) handleGetFolders(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetFolders(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -1574,6 +1604,30 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, item)
|
writeJSON(w, http.StatusOK, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdateListItem(w http.ResponseWriter, r *http.Request) {
|
||||||
|
listID := chi.URLParam(r, "id")
|
||||||
|
itemID := chi.URLParam(r, "itemId")
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Content *string `json:"content"`
|
||||||
|
Category *string `json:"category"`
|
||||||
|
Quantity *string `json:"quantity"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := s.store.UpdateListItem(itemID, req.Content, req.Category, req.Quantity)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.wsHub.BroadcastListEvent(listID, "ITEM_UPDATED", item)
|
||||||
|
writeJSON(w, http.StatusOK, item)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) {
|
||||||
listID := chi.URLParam(r, "id")
|
listID := chi.URLParam(r, "id")
|
||||||
itemID := chi.URLParam(r, "itemId")
|
itemID := chi.URLParam(r, "itemId")
|
||||||
|
|||||||
+24
-10
@@ -1034,7 +1034,7 @@ func TestDecoupledFolderSharingAPI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 7. Synchronous contents check:
|
// 7. Synchronous contents check:
|
||||||
// User B moves the document into a subfolder: "MeineOrdner/TeamProjekt/Archiv"
|
// User B (read-only recipient) cannot move documents within the shared folder:
|
||||||
moveDocPayload := map[string]string{
|
moveDocPayload := map[string]string{
|
||||||
"folderPath": "MeineOrdner/TeamProjekt/Archiv",
|
"folderPath": "MeineOrdner/TeamProjekt/Archiv",
|
||||||
}
|
}
|
||||||
@@ -1044,22 +1044,36 @@ func TestDecoupledFolderSharingAPI(t *testing.T) {
|
|||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
router.ServeHTTP(rec, req)
|
router.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("User B move doc into subfolder failed: %s", rec.Body.String())
|
t.Fatalf("Expected User B move doc in read-only shared folder to fail, got %d", rec.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// User A must now see the document synchronized at "TeamProjekt/Archiv"!
|
// User A (the owner) moves the document into a subfolder: "TeamProjekt/Archiv"
|
||||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+createdDoc.ID, nil)
|
moveDocPayload = map[string]string{
|
||||||
|
"folderPath": "TeamProjekt/Archiv",
|
||||||
|
}
|
||||||
|
b, _ = json.Marshal(moveDocPayload)
|
||||||
|
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+createdDoc.ID+"/move", bytes.NewReader(b))
|
||||||
req.Header.Set("Authorization", "Bearer "+tokenA)
|
req.Header.Set("Authorization", "Bearer "+tokenA)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
router.ServeHTTP(rec, req)
|
router.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("User A get doc failed: %s", rec.Body.String())
|
t.Fatalf("User A move doc into subfolder failed: %s", rec.Body.String())
|
||||||
}
|
}
|
||||||
var docASynced domain.Document
|
|
||||||
_ = json.Unmarshal(rec.Body.Bytes(), &docASynced)
|
// User B must now see the document synchronized at "MeineOrdner/TeamProjekt/Archiv"!
|
||||||
if docASynced.FolderPath != "TeamProjekt/Archiv" {
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+createdDoc.ID, nil)
|
||||||
t.Fatalf("expected User A to see subfolder sync at 'TeamProjekt/Archiv', got %q", docASynced.FolderPath)
|
req.Header.Set("Authorization", "Bearer "+tokenB)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("User B get doc failed: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
var docBSynced domain.Document
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &docBSynced)
|
||||||
|
if docBSynced.FolderPath != "MeineOrdner/TeamProjekt/Archiv" {
|
||||||
|
t.Fatalf("expected User B to see subfolder sync at 'MeineOrdner/TeamProjekt/Archiv', got %q", docBSynced.FolderPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,8 +148,10 @@ type Thought struct {
|
|||||||
Source string `json:"source"` // manual | voice | webhook
|
Source string `json:"source"` // manual | voice | webhook
|
||||||
Status ThoughtStatus `json:"status"`
|
Status ThoughtStatus `json:"status"`
|
||||||
IsCompleted bool `json:"isCompleted"`
|
IsCompleted bool `json:"isCompleted"`
|
||||||
ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item
|
ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item | document_block
|
||||||
ConvertedRefID string `json:"convertedRefId,omitempty"` // referenced entity ID
|
ConvertedRefID string `json:"convertedRefId,omitempty"` // referenced entity ID
|
||||||
|
DocID string `json:"docId,omitempty"`
|
||||||
|
BlockID string `json:"blockId,omitempty"`
|
||||||
DueDate string `json:"dueDate,omitempty"`
|
DueDate string `json:"dueDate,omitempty"`
|
||||||
TaskListID string `json:"taskListId,omitempty"`
|
TaskListID string `json:"taskListId,omitempty"`
|
||||||
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
|
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
|
||||||
@@ -270,6 +272,7 @@ type DocumentBlock struct {
|
|||||||
Level int `json:"level"`
|
Level int `json:"level"`
|
||||||
Position float64 `json:"position"`
|
Position float64 `json:"position"`
|
||||||
Collapsed bool `json:"collapsed,omitempty"`
|
Collapsed bool `json:"collapsed,omitempty"`
|
||||||
|
TaskID string `json:"taskId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func CompileBlocksToMarkdown(blocks []DocumentBlock) string {
|
func CompileBlocksToMarkdown(blocks []DocumentBlock) string {
|
||||||
|
|||||||
+200
-16
@@ -414,9 +414,10 @@ func (s *Store) migrate() error {
|
|||||||
// Backfill username for existing accounts where username is empty
|
// Backfill username for existing accounts where username is empty
|
||||||
_, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`)
|
_, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`)
|
||||||
|
|
||||||
// Ensure all existing document shares are strictly read-only
|
// Ensure all existing document & folder shares are strictly read-only
|
||||||
_, _ = s.db.Exec(`UPDATE doc_shares SET permission = 'read' WHERE permission != 'read'`)
|
_, _ = s.db.Exec(`UPDATE doc_shares SET permission = 'read' WHERE permission != 'read'`)
|
||||||
_, _ = s.db.Exec(`UPDATE doc_team_shares SET permission = 'read' WHERE permission != 'read'`)
|
_, _ = s.db.Exec(`UPDATE doc_team_shares SET permission = 'read' WHERE permission != 'read'`)
|
||||||
|
_, _ = s.db.Exec(`UPDATE workspace_folder_shares SET permission = 'read' WHERE permission != 'read'`)
|
||||||
|
|
||||||
// Default system settings (public registration disabled by default; invite or admin creation required)
|
// Default system settings (public registration disabled by default; invite or admin creation required)
|
||||||
_, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES
|
_, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES
|
||||||
@@ -911,6 +912,30 @@ func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueD
|
|||||||
}
|
}
|
||||||
|
|
||||||
tags := domain.ExtractTags(content)
|
tags := domain.ExtractTags(content)
|
||||||
|
if strings.EqualFold(source, "voice") {
|
||||||
|
hasDiktat := false
|
||||||
|
for _, tag := range tags {
|
||||||
|
if strings.EqualFold(tag, "diktat") {
|
||||||
|
hasDiktat = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasDiktat {
|
||||||
|
tags = append(tags, "diktat")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.EqualFold(source, "webhook") {
|
||||||
|
hasWebhook := false
|
||||||
|
for _, tag := range tags {
|
||||||
|
if strings.EqualFold(tag, "webhook") {
|
||||||
|
hasWebhook = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasWebhook {
|
||||||
|
tags = append(tags, "webhook")
|
||||||
|
}
|
||||||
|
}
|
||||||
tagsJSON, _ := json.Marshal(tags)
|
tagsJSON, _ := json.Marshal(tags)
|
||||||
|
|
||||||
t := &domain.Thought{
|
t := &domain.Thought{
|
||||||
@@ -968,6 +993,11 @@ func scanThought(rows interface {
|
|||||||
}
|
}
|
||||||
t.Tags = []string{}
|
t.Tags = []string{}
|
||||||
_ = json.Unmarshal([]byte(tagsStr), &t.Tags)
|
_ = json.Unmarshal([]byte(tagsStr), &t.Tags)
|
||||||
|
if t.ConvertedType == "document_block" && strings.Contains(t.ConvertedRefID, ":") {
|
||||||
|
parts := strings.SplitN(t.ConvertedRefID, ":", 2)
|
||||||
|
t.DocID = parts[0]
|
||||||
|
t.BlockID = parts[1]
|
||||||
|
}
|
||||||
t.Subtasks = []domain.SubTask{}
|
t.Subtasks = []domain.SubTask{}
|
||||||
if subtasksStr != "" && subtasksStr != "null" {
|
if subtasksStr != "" && subtasksStr != "null" {
|
||||||
_ = json.Unmarshal([]byte(subtasksStr), &t.Subtasks)
|
_ = json.Unmarshal([]byte(subtasksStr), &t.Subtasks)
|
||||||
@@ -1137,23 +1167,23 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
|||||||
title = title[:72] + "…"
|
title = title[:72] + "…"
|
||||||
}
|
}
|
||||||
blocks := make([]domain.DocumentBlock, 0)
|
blocks := make([]domain.DocumentBlock, 0)
|
||||||
formattedDate := domain.FormatGermanDateTime(thought.CreatedAt)
|
|
||||||
calloutText := "Ursprünglicher Gedanke aus deiner Inbox"
|
// 17. Wenn ich eine Aufgabe zu einer Notiz mache, muss die erste Zeile das "To-Do" noch mal als "To-Do" enthalten, auch wenn es schon in der Überschrift steht. Wenn es vorher schon abgehakt war, dann den Zustand natürlich übernehmen.
|
||||||
if formattedDate != "" {
|
|
||||||
calloutText += " vom " + formattedDate
|
|
||||||
}
|
|
||||||
blocks = append(blocks, domain.DocumentBlock{
|
blocks = append(blocks, domain.DocumentBlock{
|
||||||
ID: domain.NewID("blk"),
|
ID: domain.NewID("blk"),
|
||||||
Type: "callout",
|
Type: "todo",
|
||||||
Content: calloutText,
|
Content: thought.Content,
|
||||||
|
Checked: thought.IsCompleted,
|
||||||
Level: 0,
|
Level: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 17a. Die anderen Details aus der Aufgabe sollen natürlich ebenfalls übernommen werden (Notizen, Subtasks, Fälligkeit/Priorität).
|
||||||
if strings.TrimSpace(thought.Notes) != "" {
|
if strings.TrimSpace(thought.Notes) != "" {
|
||||||
blocks = append(blocks, domain.DocumentBlock{
|
blocks = append(blocks, domain.DocumentBlock{
|
||||||
ID: domain.NewID("blk"),
|
ID: domain.NewID("blk"),
|
||||||
Type: "bullet",
|
Type: "bullet",
|
||||||
Content: strings.TrimSpace(thought.Notes),
|
Content: strings.TrimSpace(thought.Notes),
|
||||||
Level: 0,
|
Level: 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if len(thought.Subtasks) > 0 {
|
if len(thought.Subtasks) > 0 {
|
||||||
@@ -1163,10 +1193,25 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
|||||||
Type: "todo",
|
Type: "todo",
|
||||||
Content: st.Title,
|
Content: st.Title,
|
||||||
Checked: st.IsCompleted,
|
Checked: st.IsCompleted,
|
||||||
Level: 0,
|
Level: 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var metaItems []string
|
||||||
|
if thought.DueDate != "" {
|
||||||
|
metaItems = append(metaItems, "Fällig: "+thought.DueDate)
|
||||||
|
}
|
||||||
|
if thought.Priority != "" && thought.Priority != "normal" {
|
||||||
|
metaItems = append(metaItems, "Priorität: "+thought.Priority)
|
||||||
|
}
|
||||||
|
if len(metaItems) > 0 {
|
||||||
|
blocks = append(blocks, domain.DocumentBlock{
|
||||||
|
ID: domain.NewID("blk"),
|
||||||
|
Type: "bullet",
|
||||||
|
Content: strings.Join(metaItems, " • "),
|
||||||
|
Level: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
if len(blocks) == 1 {
|
if len(blocks) == 1 {
|
||||||
blocks = append(blocks, domain.DocumentBlock{
|
blocks = append(blocks, domain.DocumentBlock{
|
||||||
ID: domain.NewID("blk"),
|
ID: domain.NewID("blk"),
|
||||||
@@ -2354,6 +2399,11 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
|
|||||||
}
|
}
|
||||||
if blocks != nil {
|
if blocks != nil {
|
||||||
_ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks)
|
_ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks)
|
||||||
|
for _, b := range doc.Blocks {
|
||||||
|
if b.TaskID != "" {
|
||||||
|
s.SyncTaskFromBlockCheck(doc.UserID, b.TaskID, b.Checked)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return doc, nil
|
return doc, nil
|
||||||
}
|
}
|
||||||
@@ -2888,8 +2938,8 @@ func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Do
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !shFolder.IsOwner && shFolder.Permission != "write" {
|
if !shFolder.IsOwner {
|
||||||
return nil, fmt.Errorf("keine Schreibberechtigung für diesen geteilten Ordner")
|
return nil, fmt.Errorf("nur der Eigentümer dieses geteilten Ordners kann Dateien dorthin verschieben")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update document to belong to targetSharedFolderID
|
// Update document to belong to targetSharedFolderID
|
||||||
@@ -3338,8 +3388,8 @@ func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIde
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !f.IsOwner && f.Permission != "write" {
|
if !f.IsOwner {
|
||||||
return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Ordner-Freigaben verwalten")
|
return nil, fmt.Errorf("nur der Eigentümer kann Ordner-Freigaben verwalten")
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanType := strings.TrimSpace(targetType)
|
cleanType := strings.TrimSpace(targetType)
|
||||||
@@ -3352,9 +3402,8 @@ func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIde
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if permission != "read" && permission != "write" {
|
// Geteilte Ordner können ausschließlich mit Leserberechtigung geteilt werden (wie Dokumente)
|
||||||
permission = "read"
|
permission = "read"
|
||||||
}
|
|
||||||
|
|
||||||
var targetID, targetName, email, avatarURL string
|
var targetID, targetName, email, avatarURL string
|
||||||
if cleanType == "team" {
|
if cleanType == "team" {
|
||||||
@@ -4090,6 +4139,141 @@ func (s *Store) ToggleListItem(itemID string, isCompleted bool, userID, userName
|
|||||||
return &it, nil
|
return &it, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateListItem(itemID string, content *string, category *string, quantity *string) (*domain.ListItem, error) {
|
||||||
|
now := domain.NowISO()
|
||||||
|
var it domain.ListItem
|
||||||
|
var dInt int
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
|
||||||
|
FROM list_items WHERE id = ?`,
|
||||||
|
itemID,
|
||||||
|
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
it.IsCompleted = dInt != 0
|
||||||
|
|
||||||
|
if content != nil {
|
||||||
|
it.Content = *content
|
||||||
|
}
|
||||||
|
if category != nil {
|
||||||
|
it.Category = *category
|
||||||
|
}
|
||||||
|
if quantity != nil {
|
||||||
|
it.Quantity = *quantity
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.Exec(`UPDATE list_items SET content = ?, category = ?, quantity = ? WHERE id = ?`, it.Content, it.Category, it.Quantity, itemID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID)
|
||||||
|
return &it, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateTaskFromDocBlock(userID, docID, blockID, content string, checked bool) (*domain.Thought, error) {
|
||||||
|
doc, err := s.GetDocumentByID(userID, docID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t, err := s.CreateThoughtWithDetails(userID, content, "normal", "manual", "", "", "todo", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.ConvertedType = "document_block"
|
||||||
|
t.ConvertedRefID = docID + ":" + blockID
|
||||||
|
t.DocID = docID
|
||||||
|
t.BlockID = blockID
|
||||||
|
t.IsCompleted = checked
|
||||||
|
doneInt := 0
|
||||||
|
if checked {
|
||||||
|
doneInt = 1
|
||||||
|
t.KanbanStatus = "done"
|
||||||
|
}
|
||||||
|
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = 'document_block', converted_ref_id = ?, is_completed = ?, kanban_status = ? WHERE id = ?`,
|
||||||
|
t.ConvertedRefID, doneInt, t.KanbanStatus, t.ID)
|
||||||
|
|
||||||
|
var updatedBlocks []domain.DocumentBlock
|
||||||
|
found := false
|
||||||
|
for _, b := range doc.Blocks {
|
||||||
|
if b.ID == blockID {
|
||||||
|
b.TaskID = t.ID
|
||||||
|
b.Checked = checked
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
updatedBlocks = append(updatedBlocks, b)
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
_, _ = s.UpdateDocument(userID, docID, nil, nil, nil, &updatedBlocks, nil, nil)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SyncDocBlockAndTask(userID, taskID string, isCompleted bool) {
|
||||||
|
t, err := s.GetThoughtByID(userID, taskID)
|
||||||
|
if err != nil || t == nil || t.ConvertedType != "document_block" || !strings.Contains(t.ConvertedRefID, ":") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(t.ConvertedRefID, ":", 2)
|
||||||
|
docID, blockID := parts[0], parts[1]
|
||||||
|
doc, err := s.GetDocumentByID(userID, docID)
|
||||||
|
if err != nil || doc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var updatedBlocks []domain.DocumentBlock
|
||||||
|
found := false
|
||||||
|
for _, b := range doc.Blocks {
|
||||||
|
if b.ID == blockID {
|
||||||
|
if b.Checked != isCompleted {
|
||||||
|
b.Checked = isCompleted
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updatedBlocks = append(updatedBlocks, b)
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
_, _ = s.UpdateDocument(userID, docID, nil, nil, nil, &updatedBlocks, nil, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UnlinkTaskFromDocBlock(userID, taskID string) {
|
||||||
|
t, err := s.GetThoughtByID(userID, taskID)
|
||||||
|
if err != nil || t == nil || t.ConvertedType != "document_block" || !strings.Contains(t.ConvertedRefID, ":") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(t.ConvertedRefID, ":", 2)
|
||||||
|
docID, blockID := parts[0], parts[1]
|
||||||
|
doc, err := s.GetDocumentByID(userID, docID)
|
||||||
|
if err != nil || doc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var updatedBlocks []domain.DocumentBlock
|
||||||
|
found := false
|
||||||
|
for _, b := range doc.Blocks {
|
||||||
|
if b.ID == blockID {
|
||||||
|
b.TaskID = ""
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
updatedBlocks = append(updatedBlocks, b)
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
_, _ = s.UpdateDocument(userID, docID, nil, nil, nil, &updatedBlocks, nil, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) {
|
||||||
|
if taskID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doneInt := 0
|
||||||
|
kanban := "todo"
|
||||||
|
if checked {
|
||||||
|
doneInt = 1
|
||||||
|
kanban = "done"
|
||||||
|
}
|
||||||
|
_, _ = s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ? WHERE id = ? AND user_id = ?`, doneInt, kanban, taskID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteListItem(itemID string) (string, error) {
|
func (s *Store) DeleteListItem(itemID string) (string, error) {
|
||||||
var listID string
|
var listID string
|
||||||
_ = s.db.QueryRow(`SELECT list_id FROM list_items WHERE id = ?`, itemID).Scan(&listID)
|
_ = s.db.QueryRow(`SELECT list_id FROM list_items WHERE id = ?`, itemID).Scan(&listID)
|
||||||
|
|||||||
Reference in new issue
Block a user