init
This commit is contained in:
commit
ba1f43fd8e
35 changed files with 5926 additions and 0 deletions
261
frontend/src/pages/Checklist.tsx
Normal file
261
frontend/src/pages/Checklist.tsx
Normal 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>
|
||||
)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue