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:
2026-08-03 17:17:59 +03:00
parent 9a387c5db0
commit 8a8c798eb7
5 changed files with 633 additions and 804 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -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