616 lines
22 KiB
TypeScript
616 lines
22 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import clsx from 'clsx';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
useContactFolders,
|
|
useCreateContactFolder,
|
|
useUpdateContactFolder,
|
|
useDeleteContactFolder,
|
|
useMoveContactToFolder,
|
|
} from '@/api/hooks';
|
|
import { buildFolderTree, type ContactFolderTreeNode, type ContactFolder } from '@/api/contactFolders';
|
|
import { ChevronRight, Folder, MoreVertical, Palette, Pencil, Pin, Plus, Shield, Tag, Trash2, Users } from 'lucide-react';
|
|
import { FolderPermissionDialog } from './FolderPermissionDialog';
|
|
import { useToast } from '@/components/ui/Toast';
|
|
|
|
export type ContactFilter = 'all' | 'company' | 'person' | `tag:${string}` | `folder:${string}`;
|
|
|
|
/**
|
|
* Returns true if `descendantId` is a descendant of (or equal to) `ancestorId`
|
|
* in the given flat folder list. Used to prevent circular folder moves.
|
|
*/
|
|
function isDescendantOrSelf(folders: ContactFolder[], descendantId: string, ancestorId: string): boolean {
|
|
if (descendantId === ancestorId) return true;
|
|
let current = folders.find((f) => f.id === descendantId);
|
|
while (current && current.parent_id) {
|
|
if (current.parent_id === ancestorId) return true;
|
|
current = folders.find((f) => f.id === current!.parent_id!);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export interface ContactFolderTreeProps {
|
|
selectedFilter: ContactFilter;
|
|
onSelect: (filter: ContactFilter) => void;
|
|
tags: string[];
|
|
loading?: boolean;
|
|
contacts?: { id: string; displayname: string; type: string; folder_id?: string | null }[];
|
|
multiSelectFolders?: string[];
|
|
onMultiSelectChange?: (folderIds: string[]) => void;
|
|
}
|
|
|
|
// Icons
|
|
|
|
const icon = (IconComp: React.ElementType, cls = 'w-4 h-4') => (
|
|
<IconComp className={cls} aria-hidden="true" strokeWidth={2} />
|
|
);
|
|
|
|
const chevron = (open: boolean) => (
|
|
<ChevronRight className={clsx('w-3.5 h-3.5 transition-transform flex-shrink-0', open && 'rotate-90')} aria-hidden="true" strokeWidth={2} />
|
|
);
|
|
|
|
const ICONS = {
|
|
all: Users,
|
|
folder: Folder,
|
|
tag: Tag,
|
|
edit: Pencil,
|
|
trash: Trash2,
|
|
plus: Plus,
|
|
more: MoreVertical,
|
|
palette: Palette,
|
|
pin: Pin,
|
|
};
|
|
|
|
// Dropdown Menu
|
|
|
|
interface DropdownState {
|
|
folderId: string;
|
|
anchorRect: DOMRect;
|
|
}
|
|
|
|
function FolderDropdown({
|
|
state,
|
|
onClose,
|
|
onRename,
|
|
onDelete,
|
|
onColor,
|
|
onPin,
|
|
onPermissions,
|
|
}: {
|
|
state: DropdownState;
|
|
onClose: () => void;
|
|
onRename: (id: string) => void;
|
|
onDelete: (id: string) => void;
|
|
onColor: (id: string) => void;
|
|
onPin: (id: string) => void;
|
|
onPermissions: (id: string) => void;
|
|
}) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
|
};
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, [onClose]);
|
|
|
|
const items: { label: string; icon: React.ElementType; action: () => void; danger?: boolean }[] = [
|
|
{ label: 'Umbenennen', icon: Pencil, action: () => { onRename(state.folderId); onClose(); } },
|
|
{ label: 'Farbe', icon: Palette, action: () => { onColor(state.folderId); onClose(); } },
|
|
{ label: 'Anpinnen', icon: Pin, action: () => { onPin(state.folderId); onClose(); } },
|
|
{ label: 'Rechte', icon: Shield, action: () => { onPermissions(state.folderId); onClose(); } },
|
|
{ label: 'L\u00f6schen', icon: Trash2, action: () => { onDelete(state.folderId); onClose(); }, danger: true },
|
|
];
|
|
|
|
const top = state.anchorRect.bottom + 4;
|
|
const left = Math.max(4, Math.min(state.anchorRect.right - 160, window.innerWidth - 170));
|
|
const maxHeight = Math.min(300, window.innerHeight - top - 8);
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={ref}
|
|
className="fixed z-[9999] bg-white border border-secondary-200 rounded-lg shadow-lg py-1 min-w-[160px]"
|
|
style={{ top, left, maxHeight: maxHeight > 0 ? maxHeight : undefined, overflowY: 'auto' }}
|
|
>
|
|
{items.map((item, i) => (
|
|
<button
|
|
key={i}
|
|
onClick={item.action}
|
|
className={clsx(
|
|
'w-full flex items-center gap-2 text-left px-3 py-1.5 text-sm hover:bg-secondary-100',
|
|
item.danger && 'text-red-600 hover:bg-red-50'
|
|
)}
|
|
>
|
|
<item.icon className="w-3.5 h-3.5 flex-shrink-0" strokeWidth={2} />
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|
|
|
|
// Folder Tree Item
|
|
|
|
function FolderTreeItem({
|
|
node,
|
|
depth,
|
|
selectedFilter,
|
|
onSelectFolder,
|
|
onMoreClick,
|
|
isDragOver,
|
|
onDragOver,
|
|
onDragLeave,
|
|
onDrop,
|
|
multiSelectMode,
|
|
multiSelectedFolders,
|
|
onToggleMultiSelect,
|
|
}: {
|
|
node: ContactFolderTreeNode;
|
|
depth: number;
|
|
selectedFilter: ContactFilter;
|
|
onSelectFolder: (folderId: string) => void;
|
|
onMoreClick: (e: React.MouseEvent, folderId: string) => void;
|
|
isDragOver: boolean;
|
|
onDragOver: (e: React.DragEvent, folderId: string) => void;
|
|
onDragLeave: (folderId: string) => void;
|
|
onDrop: (e: React.DragEvent, folderId: string) => void;
|
|
multiSelectMode?: boolean;
|
|
multiSelectedFolders?: string[];
|
|
onToggleMultiSelect?: (folderId: string) => void;
|
|
}) {
|
|
const [expanded, setExpanded] = useState(true);
|
|
const folderKey = `folder:${node.id}` as ContactFilter;
|
|
const isActive = selectedFilter === folderKey;
|
|
const isChecked = multiSelectedFolders?.includes(node.id) ?? false;
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center" style={{ paddingLeft: depth * 12 + 8 }}>
|
|
<div
|
|
draggable={!multiSelectMode}
|
|
onDragStart={(e) => {
|
|
if (multiSelectMode) return;
|
|
e.dataTransfer.setData('text/folder-id', node.id);
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
}}
|
|
onDragOver={(e) => onDragOver(e, node.id)}
|
|
onDragLeave={() => onDragLeave(node.id)}
|
|
onDrop={(e) => onDrop(e, node.id)}
|
|
className={clsx(
|
|
'group flex items-center gap-1 flex-1 px-2 py-1.5 text-sm rounded-md cursor-pointer',
|
|
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
|
isDragOver
|
|
? 'bg-primary-100 ring-2 ring-primary-400'
|
|
: isActive
|
|
? 'bg-primary-50 text-primary-700 font-medium'
|
|
: 'text-secondary-700 hover:bg-secondary-100',
|
|
)}
|
|
onClick={() => {
|
|
if (multiSelectMode && onToggleMultiSelect) {
|
|
onToggleMultiSelect(node.id);
|
|
} else {
|
|
setExpanded(!expanded);
|
|
onSelectFolder(node.id);
|
|
}
|
|
}}
|
|
>
|
|
{multiSelectMode ? (
|
|
<>
|
|
<input
|
|
type="checkbox"
|
|
checked={isChecked}
|
|
onChange={() => onToggleMultiSelect?.(node.id)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="w-3.5 h-3.5 rounded border-secondary-300 text-primary-600 focus:ring-primary-500 flex-shrink-0 cursor-pointer"
|
|
/>
|
|
<span
|
|
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
|
|
className="flex-shrink-0 cursor-pointer"
|
|
>
|
|
{chevron(expanded)}
|
|
</span>
|
|
</>
|
|
) : (
|
|
chevron(expanded)
|
|
)}
|
|
{icon(ICONS.folder, 'w-4 h-4 text-secondary-400')}
|
|
<span className="flex-1 truncate">{node.name}</span>
|
|
{node.contact_count > 0 && (
|
|
<span className="text-xs text-secondary-400 tabular-nums">{node.contact_count}</span>
|
|
)}
|
|
</div>
|
|
{!multiSelectMode && (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => { e.stopPropagation(); e.preventDefault(); onMoreClick(e, node.id); }}
|
|
className="flex-shrink-0 text-secondary-400 hover:text-primary-600 p-1.5 rounded hover:bg-secondary-100 transition-colors touch-manipulation"
|
|
title="Optionen"
|
|
aria-label="Optionen"
|
|
>
|
|
{icon(ICONS.more, 'w-4 h-4')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{expanded && node.children.map((child) => (
|
|
<FolderTreeItem
|
|
key={child.id}
|
|
node={child}
|
|
depth={depth + 1}
|
|
selectedFilter={selectedFilter}
|
|
onSelectFolder={onSelectFolder}
|
|
onMoreClick={onMoreClick}
|
|
isDragOver={false}
|
|
onDragOver={onDragOver}
|
|
onDragLeave={onDragLeave}
|
|
onDrop={onDrop}
|
|
multiSelectMode={multiSelectMode}
|
|
multiSelectedFolders={multiSelectedFolders}
|
|
onToggleMultiSelect={onToggleMultiSelect}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Main Component
|
|
|
|
export function ContactFolderTree({
|
|
selectedFilter,
|
|
onSelect,
|
|
tags,
|
|
loading,
|
|
contacts = [],
|
|
multiSelectFolders = [],
|
|
onMultiSelectChange,
|
|
}: ContactFolderTreeProps) {
|
|
const { t } = useTranslation();
|
|
const toast = useToast();
|
|
const [tagsOpen, setTagsOpen] = useState(true);
|
|
const [dropdown, setDropdown] = useState<DropdownState | null>(null);
|
|
const [colorPicker, setColorPicker] = useState<{ folderId: string; color: string } | null>(null);
|
|
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null);
|
|
const [multiSelectMode, setMultiSelectMode] = useState(false);
|
|
const [permDialog, setPermDialog] = useState<{ folderId: string; folderName: string } | null>(null);
|
|
|
|
const { data: folders, isLoading: foldersLoading } = useContactFolders();
|
|
const createFolderMut = useCreateContactFolder();
|
|
const updateFolderMut = useUpdateContactFolder();
|
|
const deleteFolderMut = useDeleteContactFolder();
|
|
const moveContactMut = useMoveContactToFolder();
|
|
|
|
const folderList = folders ?? [];
|
|
const tree = buildFolderTree(folderList);
|
|
|
|
const itemCls = (active: boolean) =>
|
|
clsx(
|
|
'flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm font-medium min-h-touch text-left',
|
|
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
|
active ? 'bg-primary-50 text-primary-700' : 'text-secondary-700 hover:bg-secondary-100',
|
|
);
|
|
|
|
const handleMoreClick = useCallback((e: React.MouseEvent, folderId: string) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
// Robust rect: prefer currentTarget, fallback to target.closest('button')
|
|
const btn = (e.currentTarget as HTMLElement) || ((e.target as HTMLElement)?.closest('button'));
|
|
if (!btn) return;
|
|
const rect = btn.getBoundingClientRect();
|
|
setDropdown({ folderId, anchorRect: rect });
|
|
}, []);
|
|
|
|
const handleNewFolder = () => {
|
|
const name = prompt('Ordnername:');
|
|
if (!name) return;
|
|
createFolderMut.mutate({ name }, {
|
|
onError: (err: any) => {
|
|
const msg = err?.response?.data?.detail?.detail || err?.response?.data?.detail || err?.message || 'Fehler beim Anlegen';
|
|
toast.error(typeof msg === 'string' ? msg : 'Fehler beim Anlegen des Ordners');
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleRename = (id: string) => {
|
|
const folder = folderList.find((f) => f.id === id);
|
|
const newName = prompt('Neuer Name:', folder?.name || '');
|
|
if (!newName) return;
|
|
updateFolderMut.mutate({ id, data: { name: newName } }, {
|
|
onError: (err: any) => {
|
|
const msg = err?.response?.data?.detail?.detail || err?.response?.data?.detail || err?.message || 'Fehler beim Umbenennen';
|
|
toast.error(typeof msg === 'string' ? msg : 'Fehler beim Umbenennen');
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleDelete = (id: string) => {
|
|
if (!confirm('Ordner l\u00f6schen? Kontakte bleiben erhalten, werden aber keinem Ordner mehr zugeordnet.')) return;
|
|
deleteFolderMut.mutate(id, {
|
|
onError: (err: any) => {
|
|
const msg = err?.response?.data?.detail?.detail || err?.response?.data?.detail || err?.message || 'Fehler beim L\u00f6schen';
|
|
toast.error(typeof msg === 'string' ? msg : 'Fehler beim L\u00f6schen');
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleColor = (id: string) => {
|
|
setColorPicker({ folderId: id, color: folderList.find((f) => f.id === id && (f as any).color) ? (folderList.find((f) => f.id === id) as any).color : '#3b82f6' });
|
|
};
|
|
|
|
const handleColorSelect = (color: string) => {
|
|
if (!colorPicker) return;
|
|
updateFolderMut.mutate({ id: colorPicker.folderId, data: { color } as any });
|
|
setColorPicker(null);
|
|
};
|
|
|
|
const handlePin = (id: string) => {
|
|
const folder = folderList.find((f) => f.id === id);
|
|
const pinned = (folder as any)?.pinned ?? false;
|
|
updateFolderMut.mutate({ id, data: { pinned: !pinned } as any });
|
|
};
|
|
|
|
const handlePermissions = (id: string) => {
|
|
const folder = folderList.find((f) => f.id === id);
|
|
setPermDialog({ folderId: id, folderName: folder?.name || 'Ordner' });
|
|
};
|
|
|
|
// Drag and Drop handlers
|
|
const handleDragOver = (e: React.DragEvent, folderId: string) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDragOverFolderId(folderId);
|
|
};
|
|
|
|
const handleDragLeave = (folderId: string) => {
|
|
if (dragOverFolderId === folderId) setDragOverFolderId(null);
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent, folderId: string) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDragOverFolderId(null);
|
|
|
|
// 1) Folder-to-folder drop: move dragged folder into target folder
|
|
const draggedFolderId = e.dataTransfer.getData('text/folder-id');
|
|
if (draggedFolderId) {
|
|
// Prevent dropping a folder into itself or any of its descendants
|
|
if (isDescendantOrSelf(folderList, folderId, draggedFolderId)) {
|
|
return;
|
|
}
|
|
updateFolderMut.mutate({ id: draggedFolderId, data: { parent_id: folderId } });
|
|
return;
|
|
}
|
|
|
|
// 2) Contact-to-folder drop
|
|
const contactId = e.dataTransfer.getData('text/plain');
|
|
if (!contactId) return;
|
|
moveContactMut.mutate({ contactId, folderId });
|
|
};
|
|
|
|
const handleRootDrop = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
// Folder-to-root: unparent the dragged folder
|
|
const draggedFolderId = e.dataTransfer.getData('text/folder-id');
|
|
if (draggedFolderId) {
|
|
updateFolderMut.mutate({ id: draggedFolderId, data: { parent_id: null } });
|
|
return;
|
|
}
|
|
// Contact-to-root: remove from any folder
|
|
const contactId = e.dataTransfer.getData('text/plain');
|
|
if (!contactId) return;
|
|
moveContactMut.mutate({ contactId, folderId: null });
|
|
};
|
|
|
|
const handleRootDragOver = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
};
|
|
|
|
const handleSelectFolder = useCallback((folderId: string) => {
|
|
onSelect(`folder:${folderId}` as ContactFilter);
|
|
}, [onSelect]);
|
|
|
|
const handleToggleMultiSelect = useCallback((folderId: string) => {
|
|
if (!onMultiSelectChange) return;
|
|
if (multiSelectFolders.includes(folderId)) {
|
|
onMultiSelectChange(multiSelectFolders.filter((id) => id !== folderId));
|
|
} else {
|
|
onMultiSelectChange([...multiSelectFolders, folderId]);
|
|
}
|
|
}, [multiSelectFolders, onMultiSelectChange]);
|
|
|
|
const handleToggleMultiSelectMode = () => {
|
|
setMultiSelectMode(!multiSelectMode);
|
|
if (multiSelectMode && onMultiSelectChange) {
|
|
onMultiSelectChange([]);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<nav className="space-y-0.5" aria-label={t('contacts.title')} data-testid="contact-folder-tree">
|
|
{/* Alle Kontakte */}
|
|
<button
|
|
onClick={() => onSelect('all')}
|
|
className={itemCls(selectedFilter === 'all')}
|
|
aria-current={selectedFilter === 'all' ? 'true' : undefined}
|
|
>
|
|
{icon(ICONS.all)}
|
|
<span>{t('contacts.allContacts')}</span>
|
|
</button>
|
|
|
|
{/* Folder tree header with multi-select toggle + add button */}
|
|
<div className="flex items-center justify-between px-2 pt-2 pb-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<button
|
|
onClick={handleToggleMultiSelectMode}
|
|
className={`text-secondary-400 hover:text-primary-600 p-0.5 ${multiSelectMode ? 'text-primary-600 bg-primary-50 rounded' : ''}`}
|
|
title="Mehrere Ordner auswählen"
|
|
aria-label="Mehrere Ordner auswählen"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
|
<rect x="3" y="3" width="7" height="7" rx="1" />
|
|
<rect x="14" y="3" width="7" height="7" rx="1" />
|
|
<rect x="3" y="14" width="7" height="7" rx="1" />
|
|
<rect x="14" y="14" width="7" height="7" rx="1" />
|
|
</svg>
|
|
</button>
|
|
<span className="text-xs font-semibold text-secondary-500 uppercase tracking-wide">Ordner</span>
|
|
</div>
|
|
<button
|
|
onClick={handleNewFolder}
|
|
className="text-secondary-400 hover:text-primary-600 p-0.5"
|
|
title="Neuer Ordner"
|
|
aria-label="Neuer Ordner"
|
|
>
|
|
{icon(ICONS.plus, 'w-3.5 h-3.5')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Multi-select info */}
|
|
{multiSelectMode && (
|
|
<div className="px-2 pb-1 text-[11px] text-primary-600">
|
|
{multiSelectFolders.length > 0
|
|
? `${multiSelectFolders.length} Ordner ausgewählt`
|
|
: 'Ordner durch Anklicken auswählen'}
|
|
</div>
|
|
)}
|
|
|
|
{/* Folder tree with drag-drop */}
|
|
<div
|
|
onDragOver={handleRootDragOver}
|
|
onDrop={handleRootDrop}
|
|
>
|
|
{tree.map((node) => (
|
|
<FolderTreeItem
|
|
key={node.id}
|
|
node={node}
|
|
depth={0}
|
|
selectedFilter={selectedFilter}
|
|
onSelectFolder={handleSelectFolder}
|
|
onMoreClick={handleMoreClick}
|
|
isDragOver={dragOverFolderId === node.id}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
multiSelectMode={multiSelectMode}
|
|
multiSelectedFolders={multiSelectFolders}
|
|
onToggleMultiSelect={handleToggleMultiSelect}
|
|
/>
|
|
))}
|
|
|
|
{/* Root drop zone */}
|
|
<div
|
|
onDragOver={handleRootDragOver}
|
|
onDrop={handleRootDrop}
|
|
className="min-h-[12px] mt-1 rounded-md transition-colors hover:bg-secondary-50"
|
|
/>
|
|
|
|
{(foldersLoading || loading) && (
|
|
<div className="px-2 py-1 text-xs text-secondary-400">{t('common.loading')}</div>
|
|
)}
|
|
|
|
{tree.length === 0 && !foldersLoading && !loading && (
|
|
<div className="px-2 py-1 text-xs text-secondary-400">Keine Ordner vorhanden</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tags */}
|
|
{tags.length > 0 && (
|
|
<div className="pt-1">
|
|
<button
|
|
onClick={() => setTagsOpen(!tagsOpen)}
|
|
className="flex items-center gap-1.5 w-full px-2 py-1.5 rounded-md text-xs font-semibold text-secondary-500 uppercase tracking-wide hover:bg-secondary-100 min-h-touch text-left"
|
|
aria-expanded={tagsOpen}
|
|
>
|
|
{chevron(tagsOpen)}
|
|
{icon(ICONS.tag, 'w-3.5 h-3.5')}
|
|
<span>{t('tags.title')}</span>
|
|
</button>
|
|
{tagsOpen && (
|
|
<div className="mt-0.5 space-y-0.5">
|
|
{tags.map((tag) => {
|
|
const key = `tag:${tag}` as ContactFilter;
|
|
return (
|
|
<button
|
|
key={key}
|
|
onClick={() => onSelect(key)}
|
|
style={{ paddingLeft: '32px' }}
|
|
className={itemCls(selectedFilter === key)}
|
|
aria-current={selectedFilter === key ? 'true' : undefined}
|
|
>
|
|
{icon(ICONS.tag, 'w-3.5 h-3.5')}
|
|
<span className="truncate">{tag}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{dropdown && (
|
|
<FolderDropdown
|
|
state={dropdown}
|
|
onClose={() => setDropdown(null)}
|
|
onRename={handleRename}
|
|
onDelete={handleDelete}
|
|
onColor={handleColor}
|
|
onPin={handlePin}
|
|
onPermissions={handlePermissions}
|
|
/>
|
|
)}
|
|
|
|
{permDialog && (
|
|
<FolderPermissionDialog
|
|
folderId={permDialog.folderId}
|
|
folderName={permDialog.folderName}
|
|
onClose={() => setPermDialog(null)}
|
|
/>
|
|
)}
|
|
|
|
{colorPicker && createPortal(
|
|
<>
|
|
<div className="fixed inset-0 z-[9998]" onClick={() => setColorPicker(null)} />
|
|
<div className="fixed z-[9999] bg-white border border-secondary-200 rounded-lg shadow-lg p-3 min-w-[200px]" style={{ top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}>
|
|
<div className="text-sm font-semibold text-secondary-700 mb-2">Farbe wählen</div>
|
|
<div className="grid grid-cols-6 gap-1.5 mb-3">
|
|
{['#ef4444', '#f97316', '#f59e0b', '#eab308', '#84cc16', '#22c55e', '#10b981', '#14b8a6', '#06b6d4', '#3b82f6', '#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899', '#f43f5e', '#64748b', '#475569'].map((c) => (
|
|
<button
|
|
key={c}
|
|
type="button"
|
|
onClick={() => handleColorSelect(c)}
|
|
className="w-7 h-7 rounded-md border-2 border-white shadow-sm hover:scale-110 transition-transform"
|
|
style={{ backgroundColor: c, outline: colorPicker.color === c ? '2px solid #3b82f6' : 'none' }}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="color"
|
|
value={colorPicker.color}
|
|
onChange={(e) => setColorPicker({ ...colorPicker, color: e.target.value })}
|
|
className="w-8 h-8 rounded cursor-pointer border border-secondary-200"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={colorPicker.color}
|
|
onChange={(e) => setColorPicker({ ...colorPicker, color: e.target.value })}
|
|
className="flex-1 px-2 py-1 text-sm border border-secondary-200 rounded-md"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleColorSelect(colorPicker.color)}
|
|
className="px-3 py-1 text-sm bg-primary-600 text-white rounded-md hover:bg-primary-700"
|
|
>
|
|
OK
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>,
|
|
document.body
|
|
)}
|
|
</nav>
|
|
);
|
|
}
|