feat(TaskDetail): per-field inline-редактирование вместо глобального режима
- Клик по полю редактирует только его; автосохранение по blur/Enter/выбору, Esc — отмена - Одиночный PATCH /api/tasks/:id/field-values/:fieldId + optimistic-кэш + офлайн-очередь - Строка поля: фиксированные метрики read=edit (min-h 32/40px), редактор в потоке — ноль layout shift - Удалены глобальный edit-режим, draft-машинерия, batch-сохранение и StickyActionBar - Удалён мёртвый код components/task-detail/ (NotionFieldRow, useInlineEdit, useDirtyFields) - Новый редактор checklist; составные редакторы сохраняют по закрытию - Полировка: hover-подсветка, карандаш по hover, fade-in, семантические токены, тач-таргеты 40px
This commit is contained in:
@@ -1,107 +0,0 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Pencil } from 'lucide-react';
|
||||
|
||||
interface NotionFieldRowProps {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
editable?: boolean;
|
||||
isEditing?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
editor?: React.ReactNode;
|
||||
onEnterEdit?: () => void;
|
||||
showEditHint?: boolean;
|
||||
}
|
||||
|
||||
const DOUBLE_TAP_MS = 300;
|
||||
|
||||
function isInteractiveTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) return false;
|
||||
const tag = target.tagName.toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select' || tag === 'button' || tag === 'a') return true;
|
||||
if (target.closest('button, a, input, textarea, select, [role="button"], [role="listbox"]')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function NotionFieldRow({
|
||||
label,
|
||||
required = false,
|
||||
editable = true,
|
||||
isEditing = false,
|
||||
className,
|
||||
children,
|
||||
editor,
|
||||
onEnterEdit,
|
||||
showEditHint = true,
|
||||
}: NotionFieldRowProps) {
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const lastTapRef = useRef<number>(0);
|
||||
|
||||
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||
if (!editable || isEditing) return;
|
||||
if (isInteractiveTarget(e.target)) return;
|
||||
e.stopPropagation();
|
||||
onEnterEdit?.();
|
||||
}, [editable, isEditing, onEnterEdit]);
|
||||
|
||||
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
||||
if (!editable || isEditing) return;
|
||||
if (isInteractiveTarget(e.target)) return;
|
||||
const now = Date.now();
|
||||
if (now - lastTapRef.current < DOUBLE_TAP_MS) {
|
||||
e.stopPropagation();
|
||||
onEnterEdit?.();
|
||||
}
|
||||
lastTapRef.current = now;
|
||||
}, [editable, isEditing, onEnterEdit]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rowRef}
|
||||
className={cn(
|
||||
// Mobile: single column, label on top
|
||||
// Desktop: grid with fixed label column
|
||||
"grid gap-x-3 gap-y-1 py-1.5 px-2 -mx-2 rounded transition-colors",
|
||||
"grid-cols-1",
|
||||
"md:grid-cols-[minmax(120px,140px)_1fr] md:items-start",
|
||||
editable && !isEditing && "hover:bg-muted/40 cursor-pointer group/field",
|
||||
className
|
||||
)}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
data-editable={editable}
|
||||
>
|
||||
{/* Label column — right-aligned on desktop */}
|
||||
<div className={cn(
|
||||
"flex items-center gap-1",
|
||||
"md:justify-end md:text-right md:pt-1"
|
||||
)}>
|
||||
{required && (
|
||||
<span className="text-destructive text-[10px] font-bold leading-none">*</span>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground font-medium leading-tight truncate">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Value column */}
|
||||
<div className="min-w-0">
|
||||
{isEditing && editor ? (
|
||||
<div className="relative" onClick={(e) => e.stopPropagation()}>
|
||||
{editor}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2 min-h-[22px]">
|
||||
<div className="flex-1 min-w-0 text-[13px] leading-relaxed">
|
||||
{children}
|
||||
</div>
|
||||
{editable && showEditHint && (
|
||||
<Pencil className="w-3 h-3 text-muted-foreground/0 group-hover/field:text-muted-foreground/40 transition-all shrink-0 mt-0.5" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Save, X, Paperclip } from 'lucide-react';
|
||||
|
||||
interface StickyActionBarProps {
|
||||
visible: boolean;
|
||||
isSaving?: boolean;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onAttach?: () => void;
|
||||
}
|
||||
|
||||
export function StickyActionBar({
|
||||
visible,
|
||||
isSaving = false,
|
||||
onSave,
|
||||
onCancel,
|
||||
onAttach,
|
||||
}: StickyActionBarProps) {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"sticky bottom-0 z-40 bg-background/95 backdrop-blur-sm",
|
||||
"border-t shadow-[0_-2px_10px_rgba(0,0,0,0.05)]",
|
||||
"safe-area-bottom"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-3">
|
||||
{/* Left: Attach button */}
|
||||
<div className="flex items-center gap-2">
|
||||
{onAttach && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs gap-1.5"
|
||||
onClick={onAttach}
|
||||
>
|
||||
<Paperclip className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Прикрепить файл</span>
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline">
|
||||
Есть несохраненные изменения
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right: Cancel + Save */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-4 text-xs"
|
||||
onClick={onCancel}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<X className="w-3.5 h-3.5 mr-1" />
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 px-4 text-xs bg-green-600 hover:bg-green-700 text-white"
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<Save className="w-3.5 h-3.5 mr-1" />
|
||||
{isSaving ? 'Сохранение...' : 'Сохранить'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export function useDirtyFields() {
|
||||
const [dirtyFields, setDirtyFields] = useState<Set<number>>(new Set());
|
||||
|
||||
const markDirty = useCallback((fieldId: number) => {
|
||||
setDirtyFields(prev => {
|
||||
if (prev.has(fieldId)) return prev;
|
||||
return new Set(prev).add(fieldId);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearDirty = useCallback((fieldId: number) => {
|
||||
setDirtyFields(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(fieldId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearAllDirty = useCallback(() => {
|
||||
setDirtyFields(new Set());
|
||||
}, []);
|
||||
|
||||
const isDirty = dirtyFields.size > 0;
|
||||
|
||||
return {
|
||||
dirtyFields,
|
||||
isDirty,
|
||||
markDirty,
|
||||
clearDirty,
|
||||
clearAllDirty,
|
||||
};
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
|
||||
interface UseInlineEditOptions<T> {
|
||||
initialValue: T;
|
||||
onSave: (value: T) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function useInlineEdit<T>({ initialValue, onSave }: UseInlineEditOptions<T>) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [draftValue, setDraftValue] = useState<T>(initialValue);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const originalValueRef = useRef<T>(initialValue);
|
||||
const isDirtyRef = useRef(false);
|
||||
|
||||
const enterEdit = useCallback(() => {
|
||||
setDraftValue(originalValueRef.current);
|
||||
isDirtyRef.current = false;
|
||||
setIsEditing(true);
|
||||
}, []);
|
||||
|
||||
const exitEdit = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
setDraftValue(originalValueRef.current);
|
||||
isDirtyRef.current = false;
|
||||
}, []);
|
||||
|
||||
const saveEdit = useCallback(async () => {
|
||||
if (!isDirtyRef.current) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(draftValue);
|
||||
originalValueRef.current = draftValue;
|
||||
isDirtyRef.current = false;
|
||||
setIsEditing(false);
|
||||
} catch (err) {
|
||||
// Error handled by caller (toast)
|
||||
throw err;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [draftValue, onSave]);
|
||||
|
||||
const updateDraft = useCallback((value: T) => {
|
||||
setDraftValue(value);
|
||||
isDirtyRef.current = true;
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
saveEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
exitEdit();
|
||||
}
|
||||
}, [saveEdit, exitEdit]);
|
||||
|
||||
return {
|
||||
isEditing,
|
||||
draftValue,
|
||||
isSaving,
|
||||
isDirty: () => isDirtyRef.current,
|
||||
setDraftValue: updateDraft,
|
||||
enterEdit,
|
||||
exitEdit,
|
||||
saveEdit,
|
||||
handleKeyDown,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user