feat(workspace): tabula-rasa outliner architecture, SQLite blocks/block_tags schema & echo-protected live room sync
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m12s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m12s
This commit is contained in:
1 parent
f3b3f07e04
commit
86867b0d6f
17 files changed
+3409
-7178
No files matched your search
@@ -0,0 +1,548 @@
|
||||
import React, { useState, useEffect, useRef, useLayoutEffect, useCallback } from 'react';
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
CheckSquare,
|
||||
Square,
|
||||
Trash2,
|
||||
GripVertical,
|
||||
ArrowDownLeft,
|
||||
Indent,
|
||||
Outdent,
|
||||
Code,
|
||||
Heading1,
|
||||
Heading2,
|
||||
ListTodo,
|
||||
Type,
|
||||
} from 'lucide-react';
|
||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||
|
||||
export interface OutlinerBlockProps {
|
||||
block: DocumentBlock;
|
||||
index: number;
|
||||
maxAllowedLevel: number;
|
||||
hasChildren: boolean;
|
||||
isFocused: boolean;
|
||||
canEdit: boolean;
|
||||
onFocusBlock: (blockId: string) => void;
|
||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||
onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void;
|
||||
onDeleteBlock: (blockId: string) => void;
|
||||
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
|
||||
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
|
||||
onTagClick?: (tag: string) => void;
|
||||
onDragStartBlock?: (index: number) => void;
|
||||
onDragOverBlock?: (e: React.DragEvent, index: number) => void;
|
||||
onDropBlock?: (index: number) => void;
|
||||
isDragTarget?: boolean;
|
||||
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
|
||||
}
|
||||
|
||||
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
block,
|
||||
index,
|
||||
maxAllowedLevel,
|
||||
hasChildren,
|
||||
isFocused,
|
||||
canEdit,
|
||||
onFocusBlock,
|
||||
onUpdateBlock,
|
||||
onCreateBlockBelow,
|
||||
onDeleteBlock,
|
||||
onIndentBlock,
|
||||
onNavigateFocus,
|
||||
onTagClick,
|
||||
onDragStartBlock,
|
||||
onDragOverBlock,
|
||||
onDropBlock,
|
||||
isDragTarget,
|
||||
registerTextareaRef,
|
||||
}) => {
|
||||
const [localContent, setLocalContent] = useState<string>(block.content || '');
|
||||
const [showTypeMenu, setShowTypeMenu] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const debounceTimerRef = useRef<number | null>(null);
|
||||
const latestBlockRef = useRef<DocumentBlock>(block);
|
||||
latestBlockRef.current = block;
|
||||
const localContentRef = useRef<string>(localContent);
|
||||
localContentRef.current = localContent;
|
||||
|
||||
// Sync incoming remote changes ONLY when this textarea does NOT hold active focus
|
||||
useEffect(() => {
|
||||
const isActivelyFocused =
|
||||
textareaRef.current !== null && document.activeElement === textareaRef.current;
|
||||
if (!isActivelyFocused && block.content !== localContentRef.current) {
|
||||
setLocalContent(block.content || '');
|
||||
}
|
||||
}, [block.content]);
|
||||
|
||||
// Cleanup 150ms debounce timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-resize textarea height on content or layout changes
|
||||
const adjustHeight = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.max(24, el.scrollHeight)}px`;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
adjustHeight();
|
||||
}, [localContent, block.type, block.level, adjustHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', adjustHeight);
|
||||
return () => window.removeEventListener('resize', adjustHeight);
|
||||
}, [adjustHeight]);
|
||||
|
||||
const flushDebounce = useCallback(
|
||||
(contentToFlush: string, overrides?: Partial<DocumentBlock>) => {
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
const nextBlock: DocumentBlock = {
|
||||
...latestBlockRef.current,
|
||||
content: contentToFlush,
|
||||
...overrides,
|
||||
};
|
||||
onUpdateBlock(nextBlock, true);
|
||||
},
|
||||
[onUpdateBlock]
|
||||
);
|
||||
|
||||
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (!canEdit) return;
|
||||
let val = e.target.value;
|
||||
let typeOverride: BlockType | undefined;
|
||||
let checkedOverride: boolean | undefined;
|
||||
|
||||
// Quick markdown prefix shortcuts at start of block
|
||||
if (val.startsWith('[] ') || val.startsWith('[ ] ')) {
|
||||
val = val.replace(/^\[\s?\]\s/, '');
|
||||
typeOverride = 'todo';
|
||||
checkedOverride = false;
|
||||
} else if (val.startsWith('[x] ') || val.startsWith('[X] ')) {
|
||||
val = val.replace(/^\[[xX]\]\s/, '');
|
||||
typeOverride = 'todo';
|
||||
checkedOverride = true;
|
||||
} else if (val.startsWith('# ') && block.type !== 'h1') {
|
||||
val = val.slice(2);
|
||||
typeOverride = 'h1';
|
||||
} else if (val.startsWith('## ') && block.type !== 'h2') {
|
||||
val = val.slice(3);
|
||||
typeOverride = 'h2';
|
||||
} else if (val.startsWith('### ') && block.type !== 'h3') {
|
||||
val = val.slice(4);
|
||||
typeOverride = 'h3';
|
||||
} else if (val.startsWith('/todo ')) {
|
||||
val = val.slice(6);
|
||||
typeOverride = 'todo';
|
||||
checkedOverride = false;
|
||||
} else if (val.startsWith('/code ')) {
|
||||
val = val.slice(6);
|
||||
typeOverride = 'code';
|
||||
}
|
||||
|
||||
setLocalContent(val);
|
||||
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
if (typeOverride) {
|
||||
flushDebounce(val, {
|
||||
type: typeOverride,
|
||||
checked: checkedOverride ?? latestBlockRef.current.checked,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
debounceTimerRef.current = window.setTimeout(() => {
|
||||
debounceTimerRef.current = null;
|
||||
onUpdateBlock({
|
||||
...latestBlockRef.current,
|
||||
content: val,
|
||||
});
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!canEdit) return;
|
||||
|
||||
// 1. Tab / Shift+Tab -> Strict Outliner Indent / Outdent
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
flushDebounce(localContent);
|
||||
if (e.shiftKey) {
|
||||
onIndentBlock(block.id, -1);
|
||||
} else {
|
||||
onIndentBlock(block.id, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Enter (without Shift) -> Create new block on same level
|
||||
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
flushDebounce(localContent);
|
||||
onCreateBlockBelow(block.id, localContent);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Backspace on empty block -> Outdent if indented, or delete block & focus previous
|
||||
if (e.key === 'Backspace' && localContent === '') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
if ((block.level || 0) > 0) {
|
||||
onIndentBlock(block.id, -1);
|
||||
} else {
|
||||
onDeleteBlock(block.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. ArrowUp / ArrowDown navigation between blocks
|
||||
if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) {
|
||||
const el = textareaRef.current;
|
||||
if (el && el.selectionStart === 0 && el.selectionEnd === 0) {
|
||||
e.preventDefault();
|
||||
flushDebounce(localContent);
|
||||
onNavigateFocus(block.id, 'up');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown' && !e.shiftKey && !e.altKey) {
|
||||
const el = textareaRef.current;
|
||||
if (el && el.selectionStart === localContent.length && el.selectionEnd === localContent.length) {
|
||||
e.preventDefault();
|
||||
flushDebounce(localContent);
|
||||
onNavigateFocus(block.id, 'down');
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const level = Math.max(0, block.level || 0);
|
||||
const inlineTags = Array.from(
|
||||
new Set(
|
||||
(localContent.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g) || []).map((t) => t.slice(1).toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
const setBlockType = (newType: BlockType) => {
|
||||
setShowTypeMenu(false);
|
||||
flushDebounce(localContent, {
|
||||
type: newType,
|
||||
checked: newType === 'todo' ? Boolean(block.checked) : false,
|
||||
});
|
||||
};
|
||||
|
||||
const textStyleClass =
|
||||
block.type === 'h1'
|
||||
? 'text-xl font-bold text-slate-900 dark:text-white tracking-tight'
|
||||
: block.type === 'h2'
|
||||
? 'text-lg font-semibold text-slate-800 dark:text-slate-100'
|
||||
: block.type === 'h3'
|
||||
? 'text-base font-semibold text-slate-800 dark:text-slate-200'
|
||||
: block.type === 'code'
|
||||
? 'font-mono text-xs bg-slate-900/90 text-emerald-300 px-3 py-2 rounded-lg border border-slate-700/80'
|
||||
: block.type === 'todo' && block.checked
|
||||
? 'text-sm text-slate-400 dark:text-slate-500 line-through'
|
||||
: 'text-sm text-slate-800 dark:text-slate-200';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex items-start gap-1.5 py-1 rounded-lg transition-colors ${
|
||||
isDragTarget
|
||||
? 'border-t-2 border-indigo-500 bg-indigo-50/40 dark:bg-indigo-950/20'
|
||||
: isFocused
|
||||
? 'bg-slate-100/70 dark:bg-slate-800/40'
|
||||
: 'hover:bg-slate-50/80 dark:hover:bg-slate-800/20'
|
||||
}`}
|
||||
style={{ paddingLeft: `${level * 24 + 4}px` }}
|
||||
onDragOver={(e) => onDragOverBlock?.(e, index)}
|
||||
onDrop={() => onDropBlock?.(index)}
|
||||
>
|
||||
{/* Indentation Guide Lines */}
|
||||
{level > 0 &&
|
||||
Array.from({ length: level }).map((_, lvlIdx) => (
|
||||
<div
|
||||
key={lvlIdx}
|
||||
className="pointer-events-none absolute top-0 bottom-0 w-px bg-slate-200/80 dark:bg-slate-800"
|
||||
style={{ left: `${lvlIdx * 24 + 14}px` }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Left Controls: Drag Handle + Collapse Toggle + Bullet / Checkbox */}
|
||||
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
draggable
|
||||
onDragStart={() => onDragStartBlock?.(index)}
|
||||
title="Block ziehen oder Typ ändern"
|
||||
onClick={() => setShowTypeMenu((prev) => !prev)}
|
||||
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 cursor-grab active:cursor-grabbing transition-opacity"
|
||||
>
|
||||
<GripVertical className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
flushDebounce(localContent, { collapsed: !block.collapsed })
|
||||
}
|
||||
className="p-0.5 rounded text-slate-400 hover:text-indigo-500 transition-colors"
|
||||
title={block.collapsed ? 'Unterpunkte ausklappen' : 'Unterpunkte einklappen'}
|
||||
>
|
||||
{block.collapsed ? (
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4" />
|
||||
)}
|
||||
|
||||
{block.type === 'todo' ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canEdit}
|
||||
onClick={() =>
|
||||
flushDebounce(localContent, { checked: !block.checked })
|
||||
}
|
||||
className={`p-0.5 rounded transition-colors ${
|
||||
block.checked
|
||||
? 'text-emerald-500 hover:text-emerald-600'
|
||||
: 'text-slate-400 hover:text-indigo-500'
|
||||
}`}
|
||||
title={block.checked ? 'Als offen markieren' : 'Als erledigt markieren'}
|
||||
>
|
||||
{block.checked ? (
|
||||
<CheckSquare className="w-4 h-4" />
|
||||
) : (
|
||||
<Square className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canEdit && setShowTypeMenu((prev) => !prev)}
|
||||
className="w-5 h-5 flex items-center justify-center rounded-full group/bullet"
|
||||
title="Klicken für Block-Typ"
|
||||
>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full transition-all ${
|
||||
block.collapsed && hasChildren
|
||||
? 'bg-indigo-500 ring-4 ring-indigo-500/20'
|
||||
: isFocused
|
||||
? 'bg-indigo-500 scale-110'
|
||||
: 'bg-slate-400 dark:bg-slate-500 group-hover/bullet:bg-indigo-400'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Block Type Quick Popover */}
|
||||
{showTypeMenu && canEdit && (
|
||||
<div className="absolute left-8 top-8 z-30 flex items-center gap-1 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl shadow-lg p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlockType('text')}
|
||||
className={`p-1.5 rounded-lg text-xs flex items-center gap-1 ${
|
||||
block.type === 'text' || block.type === 'paragraph' || block.type === 'bullet'
|
||||
? 'bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
title="Standard-Bullet"
|
||||
>
|
||||
<Type className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlockType('todo')}
|
||||
className={`p-1.5 rounded-lg text-xs flex items-center gap-1 ${
|
||||
block.type === 'todo'
|
||||
? 'bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
title="To-Do Checkbox"
|
||||
>
|
||||
<ListTodo className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlockType('h1')}
|
||||
className={`p-1.5 rounded-lg text-xs flex items-center gap-1 ${
|
||||
block.type === 'h1'
|
||||
? 'bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
title="Überschrift 1"
|
||||
>
|
||||
<Heading1 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlockType('h2')}
|
||||
className={`p-1.5 rounded-lg text-xs flex items-center gap-1 ${
|
||||
block.type === 'h2'
|
||||
? 'bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
title="Überschrift 2"
|
||||
>
|
||||
<Heading2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlockType('code')}
|
||||
className={`p-1.5 rounded-lg text-xs flex items-center gap-1 ${
|
||||
block.type === 'code'
|
||||
? 'bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
title="Code-Block"
|
||||
>
|
||||
<Code className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Area: Auto-Resizing Multiline Textarea + Tag Pills */}
|
||||
<div className="flex-1 min-w-0 pr-2">
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
textareaRef.current = el;
|
||||
registerTextareaRef(block.id, el);
|
||||
}}
|
||||
rows={1}
|
||||
value={localContent}
|
||||
readOnly={!canEdit}
|
||||
onFocus={() => {
|
||||
setShowTypeMenu(false);
|
||||
onFocusBlock(block.id);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (debounceTimerRef.current !== null) {
|
||||
flushDebounce(localContent);
|
||||
}
|
||||
}}
|
||||
onChange={handleContentChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
index === 0 && level === 0
|
||||
? 'Schreibe einen Punkt… (Enter = Neuer Punkt, Tab = Einrücken, #tag, [] für To-Do)'
|
||||
: ''
|
||||
}
|
||||
className={`w-full resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words ${textStyleClass}`}
|
||||
/>
|
||||
|
||||
{/* Inline clickable #tag pills */}
|
||||
{inlineTags.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1 mt-1">
|
||||
{inlineTags.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => onTagClick?.(t)}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-900/80 transition-colors"
|
||||
>
|
||||
#{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Quick-Action Bar when focused */}
|
||||
{isFocused && canEdit && (
|
||||
<div className="flex sm:hidden items-center gap-1 mt-1.5 pt-1 border-t border-slate-200/60 dark:border-slate-800/60">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
flushDebounce(localContent);
|
||||
onIndentBlock(block.id, -1);
|
||||
}}
|
||||
disabled={level === 0}
|
||||
className="p-1.5 rounded-lg text-xs text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-800 disabled:opacity-40"
|
||||
title="Ausrücken (Shift+Tab)"
|
||||
>
|
||||
<Outdent className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
flushDebounce(localContent);
|
||||
onIndentBlock(block.id, 1);
|
||||
}}
|
||||
disabled={level >= maxAllowedLevel}
|
||||
className="p-1.5 rounded-lg text-xs text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-800 disabled:opacity-40"
|
||||
title="Einrücken (Tab)"
|
||||
>
|
||||
<Indent className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
flushDebounce(localContent, {
|
||||
type: block.type === 'todo' ? 'text' : 'todo',
|
||||
});
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-xs text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-800"
|
||||
title="Als To-Do umschalten"
|
||||
>
|
||||
<ListTodo className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
flushDebounce(localContent);
|
||||
onCreateBlockBelow(block.id, localContent);
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-xs text-indigo-600 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-950/60 ml-auto flex items-center gap-1"
|
||||
>
|
||||
<ArrowDownLeft className="w-3.5 h-3.5" />
|
||||
<span>Neu</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Hover Delete Button */}
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteBlock(block.id)}
|
||||
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-1 rounded text-slate-400 hover:text-rose-500 transition-opacity shrink-0"
|
||||
title="Block löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user