feat(workspace): add help page, folder drag and drop, pinned notes section, and clean up editor UX
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 52s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 52s
This commit is contained in:
1 parent
fe1d574787
commit
fb805b30a1
5 files changed
+610
-67
No files matched your search
@@ -38,6 +38,7 @@ import {
|
||||
Circle,
|
||||
FolderTree,
|
||||
Plus,
|
||||
HelpCircle,
|
||||
} from 'lucide-react';
|
||||
import { BlockType, Document, DocumentBlock } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
@@ -45,9 +46,10 @@ import { api, triggerHaptic } from '../../lib/api';
|
||||
interface WorkspaceViewProps {
|
||||
initialDocId?: string | null;
|
||||
onStatsChange: () => void;
|
||||
onOpenHelp?: () => void;
|
||||
}
|
||||
|
||||
const ICONS = ['🚀', '💡', '📄', '🏡', '⚡', '🎯', '🍕', '🛠️', '🧠', '🔥', '🏔️', '📦', '📑', '⚙️', '📊'];
|
||||
const ICONS = ['📄', '📝', '💡', '🚀', '🏡', '⚡', '🎯', '🍕', '🛠️', '🧠', '🔥', '🏔️', '📦', '📑', '⚙️', '📊'];
|
||||
|
||||
interface FolderTreeItem {
|
||||
path: string;
|
||||
@@ -80,6 +82,7 @@ const SLASH_COMMANDS: SlashCommandItem[] = [
|
||||
export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
initialDocId,
|
||||
onStatsChange,
|
||||
onOpenHelp,
|
||||
}) => {
|
||||
// Top Workspace Tab Mode: 'editor' vs 'todos' (Global To-Do Rollup Dashboard)
|
||||
const [workspaceMode, setWorkspaceMode] = useState<'editor' | 'todos'>('editor');
|
||||
@@ -105,6 +108,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
const [newFolderParent, setNewFolderParent] = useState<string | null>(null);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
|
||||
// Sidebar Document Drag and Drop state
|
||||
const [draggingDocId, setDraggingDocId] = useState<string | null>(null);
|
||||
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
||||
|
||||
// Active focused block ref
|
||||
const [lastFocusedBlockId, setLastFocusedBlockId] = useState<string | null>(null);
|
||||
|
||||
@@ -116,7 +123,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
const [slashSearch, setSlashSearch] = useState('');
|
||||
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
|
||||
|
||||
// Drag and Drop reordering state
|
||||
// Drag and Drop reordering state for blocks
|
||||
const [draggingBlockId, setDraggingBlockId] = useState<string | null>(null);
|
||||
|
||||
// --- GLOBAL TO-DO ROLLUP STATE ---
|
||||
@@ -207,7 +214,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}
|
||||
const created = await api.createDocument({
|
||||
title: 'Neuer Gedanke / Notiz',
|
||||
icon: '💡',
|
||||
icon: '📄',
|
||||
folderPath: path,
|
||||
blocks: [
|
||||
{
|
||||
@@ -258,6 +265,30 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
await persistDocument(updatedDoc);
|
||||
};
|
||||
|
||||
const handleDropDocToFolder = async (folderPath: string, e: React.DragEvent) => {
|
||||
const docId = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId;
|
||||
if (!docId) return;
|
||||
const doc = docs.find((d) => d.id === docId);
|
||||
if (!doc) return;
|
||||
const cleanPath = folderPath.trim();
|
||||
if (getDocFolder(doc) === cleanPath) return;
|
||||
|
||||
triggerHaptic(15);
|
||||
const updatedTags = (doc.tags || []).filter((t) => !t.startsWith('folder:'));
|
||||
if (cleanPath) {
|
||||
updatedTags.push(`folder:${cleanPath}`);
|
||||
}
|
||||
const updatedDoc: Document = {
|
||||
...doc,
|
||||
folderPath: cleanPath,
|
||||
tags: updatedTags,
|
||||
};
|
||||
await persistDocument(updatedDoc);
|
||||
if (cleanPath) {
|
||||
setExpandedFolders((prev) => new Set([...prev, cleanPath]));
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartCreateFolder = (parentPath?: string | null) => {
|
||||
setNewFolderParent(parentPath || null);
|
||||
setNewFolderName('');
|
||||
@@ -801,25 +832,41 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
return { rootTree: rootFolders, rootDocs: rootOnlyDocs };
|
||||
}, [allFolderPaths, filteredDocs]);
|
||||
|
||||
const pinnedDocs = useMemo(() => {
|
||||
return docs.filter((d) => d.isPinned);
|
||||
}, [docs]);
|
||||
|
||||
const renderDocItem = (doc: Document, depth: number) => {
|
||||
const isSelected = selectedDoc?.id === doc.id && workspaceMode === 'editor';
|
||||
const isDragging = draggingDocId === doc.id;
|
||||
return (
|
||||
<div
|
||||
key={doc.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.setData('application/mindsift-doc-id', doc.id);
|
||||
e.dataTransfer.setData('text/plain', doc.id);
|
||||
setDraggingDocId(doc.id);
|
||||
}}
|
||||
onDragEnd={() => setDraggingDocId(null)}
|
||||
onClick={() => {
|
||||
setSelectedDoc(doc);
|
||||
setZoomedBlockId(null);
|
||||
setWorkspaceMode('editor');
|
||||
}}
|
||||
style={{ paddingLeft: `${depth * 14 + 16}px` }}
|
||||
className={`group flex items-center justify-between py-1.5 pr-2 border-l-2 transition-colors cursor-pointer text-xs ${
|
||||
className={`group flex items-center justify-between py-1.5 pr-2 border-l-2 transition-all cursor-pointer text-xs ${
|
||||
isDragging ? 'opacity-40 scale-[0.98]' : ''
|
||||
} ${
|
||||
isSelected
|
||||
? 'bg-panel-elevated border-accent text-main font-medium'
|
||||
: 'border-transparent text-muted hover:text-main hover:bg-canvas/50'
|
||||
}`}
|
||||
title={`${doc.title || 'Notiz'} (Ziehen zum Verschieben in Ordner)`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className="text-sm flex-shrink-0">{doc.icon}</span>
|
||||
<span className="text-sm flex-shrink-0">{doc.icon || '📄'}</span>
|
||||
<span className="truncate">{doc.title || 'Unbenannt'}</span>
|
||||
{doc.isPinned && <Pin className="w-2.5 h-2.5 text-accent flex-shrink-0" />}
|
||||
</div>
|
||||
@@ -830,14 +877,41 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
const renderFolder = (node: FolderTreeItem): React.ReactNode => {
|
||||
const isExpanded = expandedFolders.has(node.path);
|
||||
const isCreatingSubfolder = isCreatingFolder && newFolderParent === node.path;
|
||||
const isDragOver = dragOverFolder === node.path;
|
||||
const totalItems = node.docs.length + node.subfolders.length;
|
||||
|
||||
return (
|
||||
<div key={node.path} className="select-none">
|
||||
<div
|
||||
key={node.path}
|
||||
className="select-none"
|
||||
onDragOver={(e) => {
|
||||
if (draggingDocId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverFolder(node.path);
|
||||
}
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
if (dragOverFolder === node.path) {
|
||||
setDragOverFolder(null);
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverFolder(null);
|
||||
handleDropDocToFolder(node.path, e);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={() => toggleFolder(node.path)}
|
||||
style={{ paddingLeft: `${node.depth * 14 + 8}px` }}
|
||||
className="group flex items-center justify-between py-1.5 pr-2 hover:bg-panel-elevated cursor-pointer transition-colors text-xs text-muted"
|
||||
className={`group flex items-center justify-between py-1.5 pr-2 cursor-pointer transition-colors text-xs ${
|
||||
isDragOver
|
||||
? 'bg-accent/20 border-l-2 border-accent text-main font-medium'
|
||||
: 'hover:bg-panel-elevated text-muted'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-faint hover:text-muted">
|
||||
@@ -954,7 +1028,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}`}
|
||||
>
|
||||
<Layers className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Dokumente & Outliner</span>
|
||||
<span>Notizen</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -967,30 +1041,44 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}`}
|
||||
>
|
||||
<CheckSquare className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Globales Notizen-To-Do Rollup</span>
|
||||
<span>Globale To-Dos</span>
|
||||
<span className="badge-neutral font-mono text-[10px]">
|
||||
{allWorkspaceTodos.filter((t) => !t.block.checked).length}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{workspaceMode === 'editor' && selectedDoc && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<span className="badge-accent">
|
||||
<Check className="w-3 h-3" />
|
||||
{saveState === 'saving' ? 'Speichert…' : 'Gespeichert'}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
{workspaceMode === 'editor' && selectedDoc && (
|
||||
<>
|
||||
<span className="badge-accent">
|
||||
<Check className="w-3 h-3" />
|
||||
{saveState === 'saving' ? 'Speichert…' : 'Gespeichert'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportMarkdown}
|
||||
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5"
|
||||
title="Als Logseq/Markdown-Datei (.md) herunterladen"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Export (.md)</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onOpenHelp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportMarkdown}
|
||||
onClick={onOpenHelp}
|
||||
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5"
|
||||
title="Als Logseq/Markdown-Datei (.md) herunterladen"
|
||||
title="Hilfe & Dokumentation öffnen"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Export (.md)</span>
|
||||
<HelpCircle className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Hilfe</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===================================================================== */}
|
||||
@@ -1097,6 +1185,89 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1 max-h-[calc(100vh-240px)] overflow-y-auto">
|
||||
{/* Drag target for Root when dragging a note */}
|
||||
{draggingDocId && (
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverFolder('__root__');
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
if (dragOverFolder === '__root__') setDragOverFolder(null);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverFolder(null);
|
||||
handleDropDocToFolder('', e);
|
||||
}}
|
||||
className={`mx-2 my-1.5 p-2 border-2 border-dashed rounded-btn text-center text-xs transition-colors ${
|
||||
dragOverFolder === '__root__'
|
||||
? 'border-accent bg-accent/20 text-main font-semibold'
|
||||
: 'border-border text-muted bg-canvas'
|
||||
}`}
|
||||
>
|
||||
📁 In Hauptverzeichnis verschieben (ohne Ordner)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Angepinnte Notizen (Favorites) */}
|
||||
{pinnedDocs.length > 0 && !searchQuery && (
|
||||
<div className="mb-2 pb-1.5 border-b border-border">
|
||||
<div className="px-2.5 py-1 text-[10px] font-bold text-accent uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Pin className="w-3 h-3 text-accent" />
|
||||
<span>Angepinnte Notizen ({pinnedDocs.length})</span>
|
||||
</div>
|
||||
<div className="space-y-0.5 px-1">
|
||||
{pinnedDocs.map((doc) => {
|
||||
const isSelected = selectedDoc?.id === doc.id && workspaceMode === 'editor';
|
||||
return (
|
||||
<div
|
||||
key={`pinned-${doc.id}`}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.setData('application/mindsift-doc-id', doc.id);
|
||||
e.dataTransfer.setData('text/plain', doc.id);
|
||||
setDraggingDocId(doc.id);
|
||||
}}
|
||||
onDragEnd={() => setDraggingDocId(null)}
|
||||
onClick={() => {
|
||||
setSelectedDoc(doc);
|
||||
setZoomedBlockId(null);
|
||||
setWorkspaceMode('editor');
|
||||
}}
|
||||
className={`group flex items-center justify-between px-2 py-1.5 rounded-btn transition-colors cursor-pointer text-xs ${
|
||||
isSelected
|
||||
? 'bg-panel-elevated border-l-2 border-accent text-main font-medium'
|
||||
: 'text-muted hover:text-main hover:bg-canvas/50'
|
||||
}`}
|
||||
title="Angepinnte Notiz öffnen"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className="text-sm flex-shrink-0">{doc.icon || '📄'}</span>
|
||||
<span className="truncate">{doc.title || 'Unbenannt'}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
persistDocument({ ...doc, isPinned: false });
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 text-muted hover:text-accent rounded transition-opacity"
|
||||
title="Von Angepinnt entfernen"
|
||||
>
|
||||
<Pin className="w-3 h-3 text-accent" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rootTree.map(renderFolder)}
|
||||
{rootDocs.map((doc) => renderDocItem(doc, 0))}
|
||||
{filteredDocs.length === 0 && searchQuery && (
|
||||
@@ -1117,18 +1288,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Folder className="w-3.5 h-3.5 text-faint" />
|
||||
<select
|
||||
value={getDocFolder(selectedDoc)}
|
||||
onChange={(e) => handleChangeDocFolder(e.target.value)}
|
||||
className="input-field px-2 py-0.5 text-xs bg-canvas"
|
||||
>
|
||||
<option value="">(Kein Ordner / Root)</option>
|
||||
{allFolderPaths.map((fp) => (
|
||||
<option key={fp} value={fp}>
|
||||
{fp}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span>{selectedDoc.folderPath || 'Hauptverzeichnis'}</span>
|
||||
</div>
|
||||
|
||||
<span>• {selectedDoc.blocks.length} Bullets</span>
|
||||
@@ -1159,14 +1319,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
{/* ZOOM BREADCRUMB BAR (Hoisting Focus Mode) */}
|
||||
{zoomedBlock && (
|
||||
<div className="p-2.5 bg-canvas border border-accent/40 rounded-card flex items-center justify-between gap-2 text-xs">
|
||||
<div className="p-2.5 bg-canvas border border-border rounded-card flex items-center gap-2 text-xs">
|
||||
<div className="flex flex-wrap items-center gap-1.5 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomedBlockId(null)}
|
||||
className="text-accent hover:underline flex items-center gap-1 font-medium"
|
||||
className="text-muted hover:text-main flex items-center gap-1 font-medium transition-colors"
|
||||
>
|
||||
<span>{selectedDoc.icon}</span>
|
||||
<span>{selectedDoc.icon || '📄'}</span>
|
||||
<span className="truncate">{selectedDoc.title}</span>
|
||||
</button>
|
||||
|
||||
@@ -1176,7 +1336,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomedBlockId(b.id)}
|
||||
className="text-muted hover:text-main truncate max-w-[140px]"
|
||||
className="text-muted hover:text-main truncate max-w-[140px] font-medium transition-colors"
|
||||
>
|
||||
{b.content || 'Bullet'}
|
||||
</button>
|
||||
@@ -1184,18 +1344,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
))}
|
||||
|
||||
<ChevronRight className="w-3 h-3 text-faint flex-shrink-0" />
|
||||
<span className="text-main font-semibold truncate max-w-[180px]">
|
||||
<span className="text-muted font-medium truncate max-w-[180px]">
|
||||
{zoomedBlock.content || 'Aktiver Ast'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomedBlockId(null)}
|
||||
className="btn-action text-xs px-2 py-0.5 flex-shrink-0"
|
||||
>
|
||||
← Zoom aufheben
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1205,14 +1357,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowIconPicker((prev) => !prev)}
|
||||
className="w-10 h-10 panel-surface hover:bg-panel-elevated text-xl flex items-center justify-center flex-shrink-0 transition-colors rounded-card"
|
||||
className="text-2xl p-0.5 hover:scale-110 flex items-center justify-center flex-shrink-0 transition-transform cursor-pointer"
|
||||
title="Icon ändern"
|
||||
>
|
||||
{selectedDoc.icon}
|
||||
{selectedDoc.icon || '📄'}
|
||||
</button>
|
||||
|
||||
{showIconPicker && (
|
||||
<div className="absolute top-12 left-0 z-20 p-2.5 panel-surface flex flex-wrap gap-1 w-64 shadow-panel border border-border rounded-card">
|
||||
<div className="absolute top-10 left-0 z-20 p-2.5 panel-surface flex flex-wrap gap-1 w-64 shadow-panel border border-border rounded-card">
|
||||
{ICONS.map((ic) => (
|
||||
<button
|
||||
key={ic}
|
||||
@@ -1249,17 +1401,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Hint Bar for Logseq Outliner Controls */}
|
||||
<div className="flex flex-wrap items-center justify-between text-[11px] text-faint px-1 pt-0.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span><kbd className="px-1 py-0.2 bg-canvas border border-border rounded text-[10px]">Enter</kbd> Neuer Bullet</span>
|
||||
<span><kbd className="px-1 py-0.2 bg-canvas border border-border rounded text-[10px]">Tab</kbd> Einrücken</span>
|
||||
<span><kbd className="px-1 py-0.2 bg-canvas border border-border rounded text-[10px]">Shift+Tab</kbd> Ausrücken</span>
|
||||
<span><kbd className="px-1 py-0.2 bg-canvas border border-border rounded text-[10px]">/</kbd> Slash-Befehle</span>
|
||||
</div>
|
||||
<span>Dot ziehen = Verschieben · Klick = Reinhoomen</span>
|
||||
</div>
|
||||
|
||||
{/* =========================================================== */}
|
||||
{/* LOGSEQ HIERARCHICAL BULLET LIST CANVAS */}
|
||||
{/* =========================================================== */}
|
||||
@@ -1574,11 +1715,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-main flex items-center gap-2">
|
||||
<CheckSquare className="w-4 h-4 text-accent" />
|
||||
<span>Globales Notizen-To-Do Rollup</span>
|
||||
<span>Globale To-Dos</span>
|
||||
</h2>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Alle Aufgaben aus sämtlichen Markdown-Dateien und Ordnern auf einen Blick. Abhaken synchronisiert direkt zurück in die jeweilige Datei.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
Reference in new issue
Block a user