feat(workspace): recursive folder tree, link modal redesign, todo grouping & line jumping, hashtag child listing
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 23s

This commit is contained in:
sandrodach committed 2026-10-27 07:09:48 +01:00
1 parent 6c1898d489
commit 4850f9dfad
6 files changed
+802 -223

No files matched your search

@@ -26,6 +26,8 @@ export interface DocumentEditorProps {
currentUser?: User | null;
initialDoc?: Document | null;
availableFolders: string[];
autoFocusTitle?: boolean;
initialFocusedBlockId?: string | null;
onDocMetadataChange: (updated: Document) => void;
onDeleteDoc: (docId: string) => void;
onOpenShareModal: (doc: Document) => void;
@@ -143,6 +145,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
currentUser,
initialDoc,
availableFolders,
autoFocusTitle,
initialFocusedBlockId,
onDocMetadataChange,
onDeleteDoc,
onOpenShareModal,
@@ -161,6 +165,18 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const [showFolderMenu, setShowFolderMenu] = useState<boolean>(false);
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
useEffect(() => {
if (autoFocusTitle || (doc?.title === 'Neue Notiz' && !doc?.markdownCache)) {
const timer = setTimeout(() => {
if (titleInputRef.current) {
titleInputRef.current.focus();
titleInputRef.current.select();
}
}, 60);
return () => clearTimeout(timer);
}
}, [autoFocusTitle]);
useEffect(() => {
if (zoomedBlockId && !blocks.some((b) => b.id === zoomedBlockId)) {
setZoomedBlockId(null);
@@ -932,6 +948,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onMoveBlock={handleMoveBlock}
onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
initialFocusedBlockId={initialFocusedBlockId}
/>
)}
</div>
@@ -941,12 +958,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
========================================================================= */
<>
{/* Document Icon + Title Header */}
<div className="flex items-center gap-3 mb-3 relative">
<div className="flex items-center gap-3 mb-4 relative">
<div className="relative">
<button
type="button"
onClick={() => canEdit && setShowEmojiPicker((prev) => !prev)}
className="w-10 h-10 rounded-btn bg-canvas border border-border hover:border-strong flex items-center justify-center text-xl transition-colors"
className="text-2xl sm:text-3xl hover:opacity-80 transition-opacity p-0.5 rounded cursor-pointer select-none"
title="Icon ändern"
>
{doc?.icon || '📝'}
@@ -958,7 +975,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
key={emoji}
type="button"
onClick={() => handleSelectIcon(emoji)}
className="p-1.5 rounded-btn hover:bg-panel-elevated text-base flex items-center justify-center"
className="p-1.5 rounded-btn hover:bg-panel-elevated text-base flex items-center justify-center cursor-pointer"
>
{emoji}
</button>
@@ -979,23 +996,6 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
/>
</div>
{/* Extracted #Tags Summary Bar */}
{extractedTags.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 mb-4 pb-2 border-b border-border">
<Hash className="w-3.5 h-3.5 text-faint mr-0.5" />
{extractedTags.map((tag) => (
<button
key={tag}
type="button"
onClick={() => onSelectTag(tag)}
className="badge-accent text-[11px] font-medium px-2 py-0.5 rounded-btn hover:opacity-85 transition-opacity"
>
#{tag}
</button>
))}
</div>
)}
{/* Logseq Outliner Tree */}
<OutlinerTree
docId={docId}
@@ -1011,6 +1011,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onMoveBlock={handleMoveBlock}
onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
initialFocusedBlockId={initialFocusedBlockId}
/>
</>
)}
+38 -31
View File
@@ -111,22 +111,29 @@ export const LinkModal: React.FC<LinkModalProps> = ({
return (
<div
className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4 backdrop-blur-xs animate-in fade-in duration-100"
className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="panel-surface shadow-panel border border-border rounded-card max-w-md w-full overflow-hidden flex flex-col max-h-[85vh]">
{/* Modal Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-panel">
<div className="flex items-center gap-2">
<LinkIcon className="w-4 h-4 text-accent" />
<h3 className="text-sm font-bold text-main">Link einfügen</h3>
<div className="panel-surface w-full max-w-lg p-5 shadow-panel rounded-card border border-border space-y-4 animate-in fade-in zoom-in-95 duration-150">
{/* Header */}
<div className="flex items-center justify-between pb-3 border-b border-border">
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-8 h-8 rounded-btn bg-accent/15 text-accent flex items-center justify-center shrink-0">
<LinkIcon className="w-4 h-4" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-bold text-main truncate">Link einfügen</h3>
<p className="text-[11px] text-muted truncate">
Notiz intern verknüpfen oder externen Web-Link einbetten
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-1 rounded-btn text-muted hover:text-main hover:bg-panel-elevated transition-colors"
className="p-1.5 text-muted hover:text-main rounded-btn hover:bg-panel transition-colors cursor-pointer"
title="Schließen (Esc)"
>
<X className="w-4 h-4" />
@@ -134,14 +141,14 @@ export const LinkModal: React.FC<LinkModalProps> = ({
</div>
{/* Tab Switcher */}
<div className="flex border-b border-border bg-panel-elevated/40 p-1 gap-1">
<div className="flex rounded-btn bg-panel p-0.5 border border-border">
<button
type="button"
onClick={() => setTab('note')}
className={`flex-1 py-1.5 px-3 rounded-btn text-xs font-semibold flex items-center justify-center gap-1.5 transition-colors ${
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 text-xs font-semibold rounded-btn transition-colors cursor-pointer ${
tab === 'note'
? 'bg-accent text-on-accent shadow-sm'
: 'text-muted hover:text-main hover:bg-panel-elevated'
? 'bg-panel-elevated text-main shadow-xs'
: 'text-muted hover:text-main'
}`}
>
<FileText className="w-3.5 h-3.5" />
@@ -150,10 +157,10 @@ export const LinkModal: React.FC<LinkModalProps> = ({
<button
type="button"
onClick={() => setTab('web')}
className={`flex-1 py-1.5 px-3 rounded-btn text-xs font-semibold flex items-center justify-center gap-1.5 transition-colors ${
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 text-xs font-semibold rounded-btn transition-colors cursor-pointer ${
tab === 'web'
? 'bg-accent text-on-accent shadow-sm'
: 'text-muted hover:text-main hover:bg-panel-elevated'
? 'bg-panel-elevated text-main shadow-xs'
: 'text-muted hover:text-main'
}`}
>
<Globe className="w-3.5 h-3.5" />
@@ -162,7 +169,7 @@ export const LinkModal: React.FC<LinkModalProps> = ({
</div>
{/* Modal Body */}
<div className="p-4 flex-1 overflow-y-auto">
<div>
{tab === 'note' ? (
<div className="space-y-3">
<div className="relative">
@@ -172,8 +179,8 @@ export const LinkModal: React.FC<LinkModalProps> = ({
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Notiz suchen…"
className="w-full bg-input border border-border focus:border-accent text-main text-xs pl-8 pr-3 py-1.5 rounded-btn focus:outline-none transition-colors"
placeholder="Notiz nach Titel oder Ordner filtern…"
className="input-field w-full text-xs pl-8 pr-3 py-2 text-main"
/>
</div>
@@ -183,22 +190,22 @@ export const LinkModal: React.FC<LinkModalProps> = ({
<span>Notizen werden geladen…</span>
</div>
) : (
<div className="max-h-60 overflow-y-auto space-y-1 pr-1">
<div className="max-h-64 overflow-y-auto space-y-1 pr-1">
{filteredDocs.map((d) => (
<button
key={d.id}
type="button"
onClick={() => handleSelectNote(d)}
className="w-full text-left px-3 py-2 rounded-btn hover:bg-panel-elevated flex items-center justify-between text-xs transition-colors group"
className="w-full text-left px-3 py-2 rounded-btn hover:bg-panel border border-transparent hover:border-border flex items-center justify-between text-xs transition-colors group cursor-pointer"
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-base shrink-0">{d.icon || '📝'}</span>
<span className="font-medium text-main truncate group-hover:text-accent">
<span className="font-semibold text-main truncate group-hover:text-accent">
{d.title || 'Unbenannte Notiz'}
</span>
</div>
{d.folderPath && (
<span className="text-[10px] text-faint ml-2 shrink-0 truncate max-w-[100px]">
<span className="badge-neutral text-[10px] ml-2 shrink-0 truncate max-w-[120px]">
📁 {d.folderPath}
</span>
)}
@@ -206,7 +213,7 @@ export const LinkModal: React.FC<LinkModalProps> = ({
))}
{filteredDocs.length === 0 && (
<div className="py-8 text-center text-xs text-muted">
<div className="py-8 text-center text-xs text-muted border border-dashed border-border rounded-card">
{search
? `Keine Notiz für "${search}" gefunden.`
: 'Keine Notizen im Workspace vorhanden.'}
@@ -218,8 +225,8 @@ export const LinkModal: React.FC<LinkModalProps> = ({
) : (
<form onSubmit={handleWebLinkSubmit} className="space-y-3">
<div>
<label className="block text-[11px] font-semibold text-muted mb-1">
Link-Text / Titel
<label className="text-[10px] font-bold uppercase tracking-wider text-faint mb-1 block">
Link-Text / Label
</label>
<input
ref={webTitleInputRef}
@@ -227,12 +234,12 @@ export const LinkModal: React.FC<LinkModalProps> = ({
value={webTitle}
onChange={(e) => setWebTitle(e.target.value)}
placeholder="z. B. MindSift Dokumentation"
className="w-full bg-input border border-border focus:border-accent text-main text-xs px-3 py-1.5 rounded-btn focus:outline-none transition-colors"
className="input-field w-full p-2 text-xs text-main"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-muted mb-1">
<label className="text-[10px] font-bold uppercase tracking-wider text-faint mb-1 block">
Web-Adresse (URL) *
</label>
<input
@@ -241,22 +248,22 @@ export const LinkModal: React.FC<LinkModalProps> = ({
value={webUrl}
onChange={(e) => setWebUrl(e.target.value)}
placeholder="https://example.com"
className="w-full bg-input border border-border focus:border-accent text-main text-xs px-3 py-1.5 rounded-btn focus:outline-none transition-colors"
className="input-field w-full p-2 text-xs text-main"
/>
</div>
<div className="pt-2 flex justify-end gap-2">
<div className="pt-2 flex justify-end gap-2 border-t border-border">
<button
type="button"
onClick={onClose}
className="btn-action px-3 py-1.5 text-xs text-muted hover:text-main"
className="btn-action px-3 py-1.5 text-xs text-main cursor-pointer"
>
Abbrechen
</button>
<button
type="submit"
disabled={!webUrl.trim()}
className="btn-primary inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-on-accent rounded-btn disabled:opacity-50 transition-opacity"
className="btn-primary inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-on-accent rounded-btn disabled:opacity-50 transition-opacity cursor-pointer"
>
<ExternalLink className="w-3.5 h-3.5" />
<span>Link einfügen</span>
@@ -63,6 +63,7 @@ export interface OutlinerBlockProps {
onDragOverBlock?: (e: React.DragEvent, index: number) => void;
onDropBlock?: (index: number) => void;
isDragTarget?: boolean;
isTargeted?: boolean;
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
}
@@ -88,6 +89,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onDragOverBlock,
onDropBlock,
isDragTarget,
isTargeted,
registerTextareaRef,
}) => {
const [localContent, setLocalContent] = useState<string>(block.content || '');
@@ -439,9 +441,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
return (
<div
className={`group relative flex items-start gap-1 py-1 rounded-card transition-colors ${
className={`group relative flex items-start gap-1 py-1 rounded-card transition-all duration-300 ${
isDragTarget
? 'border-t-2 border-accent bg-accent/10'
: isTargeted
? 'bg-accent/15 ring-2 ring-accent'
: isFocused
? 'bg-panel-elevated'
: 'hover:bg-panel'
@@ -18,6 +18,7 @@ export interface OutlinerTreeProps {
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
onTagClick?: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
initialFocusedBlockId?: string | null;
}
export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
@@ -34,14 +35,38 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
onMoveBlock,
onTagClick,
onWikiLinkClick,
initialFocusedBlockId,
}) => {
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(
initialFocusedBlockId || null
);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const textareaMapRef = useRef<Map<string, HTMLTextAreaElement>>(new Map());
const pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' } | null>(null);
useEffect(() => {
if (initialFocusedBlockId) {
setHighlightedBlockId(initialFocusedBlockId);
const timer = setTimeout(() => {
focusBlockInput(initialFocusedBlockId, 'end');
const el = textareaMapRef.current.get(initialFocusedBlockId);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 80);
const fadeTimer = setTimeout(() => {
setHighlightedBlockId(null);
}, 2500);
return () => {
clearTimeout(timer);
clearTimeout(fadeTimer);
};
}
}, [initialFocusedBlockId]);
const registerTextareaRef = useCallback((blockId: string, el: HTMLTextAreaElement | null) => {
if (el) {
textareaMapRef.current.set(blockId, el);
@@ -304,6 +329,7 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
onDragOverBlock={handleDragOverBlock}
onDropBlock={handleDropBlock}
isDragTarget={dragOverIndex === index && draggedIndex !== index}
isTargeted={highlightedBlockId === block.id}
registerTextareaRef={registerTextareaRef}
/>
))}
File diff suppressed because it is too large. Load diff
+19 -5
View File
@@ -2404,11 +2404,18 @@ func (s *Store) GetWorkspaceTags(userID string) ([]domain.TagSummary, error) {
JOIN team_members tm ON tm.team_id = dts2.team_id
WHERE tm.user_id = ?
) dts ON d.id = dts.doc_id
WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL)
LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id
LEFT JOIN (
SELECT wfs2.folder_id
FROM workspace_folder_shares wfs2
WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?)
OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
) wfs ON d.shared_folder_id = wfs.folder_id
WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL OR (wsf.id IS NOT NULL AND (wsf.owner_id = ? OR wfs.folder_id IS NOT NULL)))
AND d.is_archived = 0
GROUP BY t.tag
ORDER BY count DESC, t.tag ASC`,
userID, userID, userID, userID,
userID, userID, userID, userID, userID, userID, userID,
)
if err != nil {
return nil, err
@@ -2460,7 +2467,7 @@ func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) {
rows, err := s.db.Query(
`SELECT t.block_id, d.id, d.title, d.icon, d.folder_path, d.blocks_json, d.updated_at, d.user_id,
COALESCE(u.display_name, '') as owner_name,
COALESCE(s.permission, dts.permission, '') as shared_permission
COALESCE(s.permission, dts.permission, wfs.permission, '') as shared_permission
FROM block_tags t
JOIN documents d ON t.document_id = d.id
LEFT JOIN users u ON d.user_id = u.id
@@ -2471,11 +2478,18 @@ func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) {
JOIN team_members tm ON tm.team_id = dts2.team_id
WHERE tm.user_id = ?
) dts ON d.id = dts.doc_id
WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL)
LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id
LEFT JOIN (
SELECT wfs2.folder_id, wfs2.permission
FROM workspace_folder_shares wfs2
WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?)
OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
) wfs ON d.shared_folder_id = wfs.folder_id
WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL OR (wsf.id IS NOT NULL AND (wsf.owner_id = ? OR wfs.folder_id IS NOT NULL)))
AND t.tag = ?
AND d.is_archived = 0
ORDER BY d.updated_at DESC`,
userID, userID, userID, userID, cleanTag,
userID, userID, userID, userID, userID, userID, userID, cleanTag,
)
if err != nil {
return nil, err