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,261 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useSSE } from '../hooks/useSSE'
import { useLocalStorage } from '../hooks/useLocalStorage'
import ChecklistItem from '../components/ChecklistItem'
import type { ChecklistItem as ChecklistItemType } from '../types'
export default function Checklist() {
const { uuid } = useParams<{ uuid: string }>()
const navigate = useNavigate()
const { items, isConnected, error } = useSSE(uuid || '')
const { saveChecklist, getChecklistName } = useLocalStorage()
const [newItemContent, setNewItemContent] = useState('')
const [isAddingItem, setIsAddingItem] = useState(false)
// Save checklist to local storage if not already saved
useEffect(() => {
if (uuid && !getChecklistName(uuid)) {
// Generate a default name if not in local storage
const defaultName = `Checklist ${new Date().toLocaleDateString()}`
saveChecklist({
uuid,
name: defaultName,
createdAt: new Date().toISOString()
})
}
}, [uuid, saveChecklist, getChecklistName])
const buildItemTree = (items: ChecklistItemType[]): ChecklistItemType[] => {
const itemMap = new Map<number, ChecklistItemType>()
const rootItems: ChecklistItemType[] = []
// Create a map of all items
items.forEach(item => {
itemMap.set(item.id, { ...item, children: [] })
})
// Build the tree structure
items.forEach(item => {
if (item.parent_id === null) {
rootItems.push(itemMap.get(item.id)!)
} else {
const parent = itemMap.get(item.parent_id)
if (parent) {
if (!parent.children) parent.children = []
parent.children.push(itemMap.get(item.id)!)
}
}
})
return rootItems
}
const addItem = async (content: string, parentId?: number) => {
if (!content.trim() || !uuid) return
setIsAddingItem(true)
try {
const response = await fetch(`/api/checklists/${uuid}/items`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: content.trim(),
parent_id: parentId || null
}),
})
if (!response.ok) {
throw new Error('Failed to add item')
}
} catch (error) {
console.error('Error adding item:', error)
alert('Failed to add item')
} finally {
setIsAddingItem(false)
}
}
const updateItem = async (id: number, updates: Partial<ChecklistItemType>) => {
if (!uuid) return
try {
const response = await fetch(`/api/checklists/${uuid}/items/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updates),
})
if (!response.ok) {
throw new Error('Failed to update item')
}
} catch (error) {
console.error('Error updating item:', error)
throw error
}
}
const deleteItem = async (id: number) => {
if (!uuid) return
try {
const response = await fetch(`/api/checklists/${uuid}/items/${id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to delete item')
}
} catch (error) {
console.error('Error deleting item:', error)
throw error
}
}
const lockItem = async (id: number, user: string) => {
if (!uuid) return
try {
const response = await fetch(`/api/checklists/${uuid}/items/${id}/lock`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ user }),
})
if (!response.ok) {
throw new Error('Failed to lock item')
}
} catch (error) {
console.error('Error locking item:', error)
throw error
}
}
const handleAddItem = async () => {
await addItem(newItemContent)
setNewItemContent('')
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleAddItem()
}
}
const renderItems = (items: ChecklistItemType[]) => {
return items.map(item => (
<ChecklistItem
key={item.id}
item={item}
onUpdate={updateItem}
onDelete={deleteItem}
onLock={lockItem}
children={item.children || []}
/>
))
}
if (!uuid) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-red-600 dark:text-red-400 text-lg font-medium">Invalid checklist ID</div>
</div>
)
}
const checklistName = getChecklistName(uuid) || 'Untitled Checklist'
const itemTree = buildItemTree(items)
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<div className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
<div className="max-w-4xl mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/')}
className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-200"
>
<span className="text-xl"></span>
<span className="font-medium">Back</span>
</button>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{checklistName}</h1>
</div>
<div className="flex items-center gap-2">
{isConnected ? (
<span className="flex items-center gap-2 text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/30 px-3 py-1 rounded-full text-sm font-medium">
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
Connected
</span>
) : (
<span className="flex items-center gap-2 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 px-3 py-1 rounded-full text-sm font-medium">
<span className="w-2 h-2 bg-red-500 rounded-full"></span>
Disconnected
</span>
)}
</div>
</div>
</div>
</div>
{/* Error Message */}
{error && (
<div className="max-w-4xl mx-auto px-4 py-4">
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-center gap-2">
<span className="text-red-600 dark:text-red-400"></span>
<span className="text-red-800 dark:text-red-200 font-medium">{error}</span>
</div>
</div>
</div>
)}
{/* Main Content */}
<div className="max-w-4xl mx-auto px-4 py-6">
{/* Add Item Section */}
<div className=" shadow-sm p-1 mb-1">
<div className="flex gap-3">
<input
type="text"
placeholder="Add a new item..."
value={newItemContent}
onChange={(e) => setNewItemContent(e.target.value)}
onKeyPress={handleKeyPress}
disabled={isAddingItem || !isConnected}
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:text-gray-500 dark:disabled:text-gray-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
/>
<button
onClick={handleAddItem}
disabled={isAddingItem || !newItemContent.trim() || !isConnected}
className="px-6 py-2 bg-blue-600 dark:bg-blue-500 text-white font-medium rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{isAddingItem ? 'Adding...' : 'Add'}
</button>
</div>
</div>
{/* Items Section */}
<div className=" p-2">
{itemTree.length === 0 ? (
<div className="text-center py-12">
<div className="text-gray-400 dark:text-gray-500 text-6xl mb-4">📝</div>
<p className="text-gray-600 dark:text-gray-300 text-lg font-medium">No items yet</p>
<p className="text-gray-500 dark:text-gray-400 mt-2">Add your first item above to get started!</p>
</div>
) : (
<div className="space-y-1">
{renderItems(itemTree)}
</div>
)}
</div>
</div>
</div>
)
}