cheekylist/frontend/src/components/ChecklistItem.tsx
2025-07-25 16:15:22 +02:00

292 lines
No EOL
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useRef, useEffect } from 'react'
import type { ChecklistItem as ChecklistItemType } from '../types'
import DependencyManager from './DependencyManager'
interface ChecklistItemProps {
item: ChecklistItemType
onUpdate: (id: number, updates: Partial<ChecklistItemType>) => Promise<void>
onDelete: (id: number) => Promise<void>
onLock: (id: number, user: string) => Promise<void>
depth?: number
children?: ChecklistItemType[]
allItems?: ChecklistItemType[]
}
export default function ChecklistItem({
item,
onUpdate,
onDelete,
onLock,
depth = 0,
children = [],
allItems = []
}: ChecklistItemProps) {
const [isEditing, setIsEditing] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isDependencyModalOpen, setIsDependencyModalOpen] = useState(false)
const [userName, setUserName] = useState('')
const contentRef = useRef<HTMLSpanElement>(null)
const isLocked = item.locked_by && item.lock_until && new Date(item.lock_until) > new Date()
const isLockedByMe = isLocked && item.locked_by === userName
// Check if all dependencies are completed
const dependenciesCompleted = item.dependencies?.every(depId => {
const depItem = allItems.find(i => i.id === depId)
return depItem?.checked
}) ?? true
// Check if item can be completed (all dependencies met)
const canComplete = dependenciesCompleted || item.checked
// Get dependency items for display
const dependencyItems = item.dependencies?.map(depId =>
allItems.find(i => i.id === depId)
).filter(Boolean) ?? []
useEffect(() => {
// Generate a random user name if not set
if (!userName) {
const randomName = `user_${Math.random().toString(36).substr(2, 9)}`
setUserName(randomName)
}
}, [userName])
useEffect(() => {
if (isEditing && contentRef.current) {
contentRef.current.focus()
// Select all text when entering edit mode
const range = document.createRange()
range.selectNodeContents(contentRef.current)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
}
}, [isEditing])
const handleEdit = async () => {
if (isLocked && !isLockedByMe) {
alert('This item is being edited by someone else')
return
}
if (!isLockedByMe) {
try {
await onLock(item.id, userName)
} catch (error) {
console.error('Failed to lock item:', error)
return
}
}
setIsEditing(true)
}
const handleSave = async () => {
const newContent = contentRef.current?.textContent?.trim() || ''
if (newContent === '') return
try {
await onUpdate(item.id, { content: newContent })
setIsEditing(false)
} catch (error) {
console.error('Failed to update item:', error)
}
}
const handleCancel = () => {
if (contentRef.current) {
contentRef.current.textContent = item.content
}
setIsEditing(false)
}
const handleDelete = async () => {
if (confirm('Are you sure you want to delete this item?')) {
try {
setIsDeleting(true)
await onDelete(item.id)
} catch (error) {
console.error('Failed to delete item:', error)
} finally {
setIsDeleting(false)
}
}
}
const handleToggleCheck = async () => {
// Don't allow unchecking if already checked
if (item.checked) {
try {
await onUpdate(item.id, { checked: false })
} catch (error) {
console.error('Failed to uncheck item:', error)
}
return
}
// Check if dependencies are met before allowing completion
if (!dependenciesCompleted) {
alert(`Cannot complete this item. The following dependencies must be completed first:\n\n${dependencyItems.map(dep => `${dep?.content}`).join('\n')}`)
return
}
try {
await onUpdate(item.id, { checked: true })
} catch (error) {
console.error('Failed to toggle item:', error)
if (error instanceof Error && error.message.includes('dependency')) {
alert(error.message)
}
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSave()
} else if (e.key === 'Escape') {
e.preventDefault()
handleCancel()
}
}
const handleBlur = () => {
// Small delay to allow for button clicks
setTimeout(() => {
if (isEditing) {
handleSave()
}
}, 100)
}
return (
<li className="group">
<div className="flex items-start gap-3 p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-all duration-200 border border-transparent hover:border-gray-200 dark:hover:border-gray-700">
{/* Checkbox */}
<div className="flex-shrink-0 mt-0.5">
<input
type="checkbox"
checked={item.checked}
onChange={handleToggleCheck}
disabled={Boolean(isLocked && !isLockedByMe) || (!item.checked && !canComplete)}
className={`w-5 h-5 text-blue-600 bg-white dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500 ${
!item.checked && !canComplete ? 'cursor-not-allowed' : ''
}`}
title={!item.checked && !canComplete ? 'Complete dependencies first' : ''}
/>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<span
ref={contentRef}
contentEditable={isEditing}
suppressContentEditableWarning={true}
className={`block w-full text-base leading-relaxed break-words outline-none transition-all duration-200 ${
isEditing
? 'px-3 py-2 border-2 border-blue-500 rounded-lg focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-800 bg-white dark:bg-gray-800 shadow-sm'
: 'cursor-pointer hover:text-blue-600 dark:hover:text-blue-400'
} ${
item.checked
? 'line-through text-gray-500 dark:text-gray-500 opacity-75'
: 'text-gray-900 dark:text-gray-100'
} ${
!item.checked && !canComplete ? 'opacity-60' : ''
}`}
onClick={!isEditing ? handleEdit : undefined}
onKeyDown={isEditing ? handleKeyDown : undefined}
onBlur={isEditing ? handleBlur : undefined}
>
{item.content}
</span>
{/* Dependency warning */}
{!item.checked && !canComplete && dependencyItems.length > 0 && (
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
Depends on: {dependencyItems.map(dep => dep?.content).join(', ')}
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
{/* Dependency indicators */}
{item.dependencies && item.dependencies.length > 0 && (
<div className="flex items-center gap-1">
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2 py-1 rounded-full whitespace-nowrap border ${
dependenciesCompleted
? 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
: 'text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800'
}`}>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
</svg>
{dependenciesCompleted ? 'Ready' : `${item.dependencies.length} deps`}
</span>
</div>
)}
{isLocked && !isLockedByMe && (
<span className="inline-flex items-center gap-1 text-xs font-medium text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 px-2 py-1 rounded-full whitespace-nowrap border border-red-200 dark:border-red-800">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
</svg>
{item.locked_by}
</span>
)}
{!isEditing && (
<>
<button
onClick={() => setIsDependencyModalOpen(true)}
className="p-1.5 text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-md transition-all duration-200 group/deps"
title="Manage dependencies"
>
<svg className="w-4 h-4 group-hover/deps:scale-110 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</button>
<button
onClick={handleDelete}
disabled={isDeleting}
className="p-1.5 text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md transition-all duration-200 disabled:opacity-30 disabled:cursor-not-allowed group/delete"
title="Delete item"
>
<svg className="w-4 h-4 group-hover/delete:scale-110 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</>
)}
</div>
</div>
{/* Children */}
{children.length > 0 && (
<div className="ml-8 mt-2 space-y-2 border-l-2 border-gray-200 dark:border-gray-700 pl-4">
{children.map(child => (
<ChecklistItem
key={child.id}
item={child}
onUpdate={onUpdate}
onDelete={onDelete}
onLock={onLock}
depth={depth + 1}
allItems={allItems}
/>
))}
</div>
)}
{/* Dependency Manager Modal */}
<DependencyManager
item={item}
allItems={allItems}
onUpdate={onUpdate}
onClose={() => setIsDependencyModalOpen(false)}
isOpen={isDependencyModalOpen}
/>
</li>
)
}