This commit is contained in:
lubiana 2025-07-22 00:00:34 +02:00
commit ba1f43fd8e
Signed by: lubiana
SSH key fingerprint: SHA256:vW1EA0fRR3Fw+dD/sM0K+x3Il2gSry6YRYHqOeQwrfk
35 changed files with 5926 additions and 0 deletions

View file

@ -0,0 +1,195 @@
import { useState, useRef, useEffect } from 'react'
import type { ChecklistItem as ChecklistItemType } from '../types'
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[]
}
export default function ChecklistItem({
item,
onUpdate,
onDelete,
onLock,
depth = 0,
children = []
}: ChecklistItemProps) {
const [isEditing, setIsEditing] = useState(false)
const [isDeleting, setIsDeleting] = 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
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 () => {
try {
await onUpdate(item.id, { checked: !item.checked })
} catch (error) {
console.error('Failed to toggle item:', error)
}
}
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 (
<div className={`${depth > 0 ? 'ml-4 border-l-2 border-gray-200 dark:border-gray-700 pl-3' : ''}`}>
<div className="flex items-center justify-between py-2 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg px-2 transition-colors duration-200">
<div className="flex items-center gap-3 flex-1 min-w-0">
<input
type="checkbox"
checked={item.checked}
onChange={handleToggleCheck}
disabled={Boolean(isLocked && !isLockedByMe)}
className="w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed"
/>
<span
ref={contentRef}
contentEditable={isEditing}
suppressContentEditableWarning={true}
className={`flex-1 text-sm text-gray-700 dark:text-gray-300 break-words outline-none ${
isEditing
? 'px-2 py-1 border-2 border-blue-500 rounded focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-800 bg-white dark:bg-gray-800'
: 'cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200'
} ${
item.checked ? 'line-through text-gray-500 dark:text-gray-500' : ''
}`}
onClick={!isEditing ? handleEdit : undefined}
onKeyDown={isEditing ? handleKeyDown : undefined}
onBlur={isEditing ? handleBlur : undefined}
>
{item.content}
</span>
</div>
<div className="flex items-center gap-2 ml-2">
{isLocked && !isLockedByMe && (
<span className="text-xs text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900/30 px-2 py-1 rounded-full whitespace-nowrap">
🔒 {item.locked_by}
</span>
)}
{!isEditing && (
<>
<button
onClick={handleDelete}
disabled={isDeleting}
className="p-1 text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors duration-200 disabled:opacity-30 disabled:cursor-not-allowed"
title="Delete item"
>
🗑
</button>
</>
)}
</div>
</div>
{children.length > 0 && (
<div className="mt-1">
{children.map(child => (
<ChecklistItem
key={child.id}
item={child}
onUpdate={onUpdate}
onDelete={onDelete}
onLock={onLock}
depth={depth + 1}
/>
))}
</div>
)}
</div>
)
}