Files
XCDesktop/src/modules/todo/TodoPage.tsx

384 lines
14 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback } from 'react'
import { Plus, Trash2, ChevronLeft, ChevronRight, ListTodo } from 'lucide-react'
import { getTodoData, addTodoItem, toggleTodoItem, updateTodoItem, deleteTodoItem } from './api'
import { WEEK_DAYS } from '@shared/constants'
import { getTodayDate, getTomorrowDate } from '@shared/utils/date'
import type { DayTodo } from './types'
const formatDateDisplay = (dateStr: string): string => {
const [year, month, day] = dateStr.split('-')
const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day))
const weekDay = WEEK_DAYS[date.getDay()]
return `${parseInt(month)}${parseInt(day)}${weekDay}`
}
const isToday = (dateStr: string): boolean => {
return dateStr === getTodayDate()
}
const isTomorrow = (dateStr: string): boolean => {
return dateStr === getTomorrowDate()
}
const isEditable = (dateStr: string): boolean => {
const today = getTodayDate()
return dateStr >= today
}
export const TodoPage: React.FC = () => {
const [dayTodos, setDayTodos] = useState<DayTodo[]>([])
const [currentYear, setCurrentYear] = useState(new Date().getFullYear())
const [currentMonth, setCurrentMonth] = useState(new Date().getMonth() + 1)
const [loading, setLoading] = useState(true)
const [editingItem, setEditingItem] = useState<{ date: string; index: number } | null>(null)
const [editContent, setEditContent] = useState('')
const [addingDate, setAddingDate] = useState<string | null>(null)
const [newTodoContent, setNewTodoContent] = useState('')
const [isInitialLoad, setIsInitialLoad] = useState(true)
const inputRef = useRef<HTMLTextAreaElement>(null)
const editInputRef = useRef<HTMLTextAreaElement>(null)
const adjustTextAreaHeight = (el: HTMLTextAreaElement) => {
const baseHeight = 28 // 1.75rem = 28px
el.style.height = baseHeight + 'px'
el.style.height = Math.max(el.scrollHeight, baseHeight) + 'px'
}
const loadTodoData = useCallback(async (year: number, month: number, isInitial: boolean = false) => {
if (isInitial) {
setLoading(true)
}
try {
const data = await getTodoData(year, month)
setDayTodos(data.dayTodos)
} catch (error) {
console.error('Failed to load TODO:', error)
setDayTodos([])
} finally {
if (isInitial) {
setLoading(false)
setIsInitialLoad(false)
}
}
}, [])
useEffect(() => {
loadTodoData(currentYear, currentMonth, isInitialLoad)
}, [currentYear, currentMonth, loadTodoData, isInitialLoad])
useEffect(() => {
if (addingDate && inputRef.current) {
inputRef.current.focus()
adjustTextAreaHeight(inputRef.current)
}
}, [addingDate])
useEffect(() => {
if (editingItem && editInputRef.current) {
adjustTextAreaHeight(editInputRef.current)
}
}, [editingItem])
const handlePrevMonth = () => {
if (currentMonth === 1) {
setCurrentMonth(12)
setCurrentYear(currentYear - 1)
} else {
setCurrentMonth(currentMonth - 1)
}
}
const handleNextMonth = () => {
if (currentMonth === 12) {
setCurrentMonth(1)
setCurrentYear(currentYear + 1)
} else {
setCurrentMonth(currentMonth + 1)
}
}
const handleStartAdd = (date: string) => {
setAddingDate(date)
setNewTodoContent('')
}
const handleCancelAdd = () => {
setAddingDate(null)
setNewTodoContent('')
}
const handleAddTodo = async (date: string, continueAdding: boolean = false) => {
const content = newTodoContent.trim()
if (!content) {
setAddingDate(null)
setNewTodoContent('')
return
}
try {
const data = await addTodoItem(currentYear, currentMonth, date, content)
setDayTodos(data.dayTodos)
if (continueAdding) {
setNewTodoContent('')
} else {
setAddingDate(null)
setNewTodoContent('')
}
} catch (error) {
console.error('Failed to add TODO:', error)
}
}
const handleToggleTodo = async (date: string, itemIndex: number, completed: boolean) => {
try {
const data = await toggleTodoItem(currentYear, currentMonth, date, itemIndex, completed)
setDayTodos(data.dayTodos)
} catch (error) {
console.error('Failed to toggle TODO:', error)
}
}
const handleStartEdit = (date: string, index: number, content: string) => {
setEditingItem({ date, index })
setEditContent(content)
setAddingDate(null)
}
const handleSaveEdit = async () => {
if (!editingItem) return
const { date, index } = editingItem
const newContent = editContent.trim()
if (!newContent) {
try {
const data = await deleteTodoItem(currentYear, currentMonth, date, index)
setDayTodos(data.dayTodos)
} catch (error) {
console.error('Failed to delete TODO:', error)
} finally {
setEditingItem(null)
setEditContent('')
}
return
}
try {
const data = await updateTodoItem(currentYear, currentMonth, date, index, newContent)
setDayTodos(data.dayTodos)
} catch (error) {
console.error('Failed to update TODO:', error)
} finally {
setEditingItem(null)
setEditContent('')
}
}
const handleCancelEdit = () => {
setEditingItem(null)
setEditContent('')
}
const handleDeleteTodo = async (date: string, itemIndex: number) => {
try {
const data = await deleteTodoItem(currentYear, currentMonth, date, itemIndex)
setDayTodos(data.dayTodos)
} catch (error) {
console.error('Failed to delete TODO:', error)
}
}
const handleKeyDown = (e: React.KeyboardEvent, date: string) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleAddTodo(date, true)
} else if (e.key === 'Escape') {
handleCancelAdd()
}
}
const handleEditKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSaveEdit()
} else if (e.key === 'Escape') {
handleCancelEdit()
}
}
const todayDate = getTodayDate()
const tomorrowDate = getTomorrowDate()
const now = new Date()
const isCurrentMonth = currentYear === now.getFullYear() && currentMonth === now.getMonth() + 1
const allDates = [...new Set([
...dayTodos.map(d => d.date),
...(isCurrentMonth ? [todayDate, tomorrowDate] : [])
])].sort()
return (
<div className="max-w-4xl mx-auto w-full px-14 py-12 pb-40">
<h1 className="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-8 flex items-center gap-2">
<ListTodo className="w-8 h-8" />
TODO
</h1>
<div className="flex items-center justify-center mb-6">
<div className="flex items-center gap-2">
<button
onClick={handlePrevMonth}
className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<ChevronLeft size={18} />
</button>
<span className="text-base font-medium text-gray-700 dark:text-gray-300 min-w-[80px] text-center">
{currentYear}{currentMonth}
</span>
<button
onClick={handleNextMonth}
className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<ChevronRight size={18} />
</button>
</div>
</div>
{loading && isInitialLoad ? (
<div className="text-center text-gray-500 dark:text-gray-400 py-12">
...
</div>
) : allDates.length === 0 ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
<ListTodo size={48} className="mx-auto mb-4 opacity-30" />
<p></p>
</div>
) : (
<div className="space-y-4 pb-8">
{allDates.map((date) => {
const dayTodo = dayTodos.find(d => d.date === date)
const isTodayDate = isToday(date)
const isTomorrowDate = isTomorrow(date)
const canEdit = isEditable(date)
return (
<div key={date} className="pt-4 space-y-2">
<div className="flex items-center gap-2">
<h3 className={`text-lg font-semibold ${isTodayDate
? 'text-gray-900 dark:text-gray-100'
: isTomorrowDate
? 'text-gray-700 dark:text-gray-300'
: 'text-gray-500 dark:text-gray-500'
}`}>
{formatDateDisplay(date)}
</h3>
{isTodayDate && (
<span className="px-2 py-0.5 text-xs bg-gray-800 dark:bg-gray-200 text-white dark:text-gray-800 rounded-full">
</span>
)}
{isTomorrowDate && (
<span className="px-2 py-0.5 text-xs bg-gray-400 dark:bg-gray-600 text-white rounded-full">
</span>
)}
{canEdit && (
<button
onClick={() => handleStartAdd(date)}
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
title="添加任务"
>
<Plus size={16} />
</button>
)}
</div>
<div className="pl-2 space-y-2">
{dayTodo?.items.map((item, index) => (
<div
key={item.id}
className="flex items-center gap-3 group"
>
<button
onClick={() => canEdit && handleToggleTodo(date, index, !item.completed)}
disabled={!canEdit}
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors ${item.completed
? 'bg-gray-600 dark:bg-gray-400 border-gray-600 dark:border-gray-400 text-white'
: 'border-gray-400 dark:border-gray-500'
} ${canEdit ? 'hover:border-gray-600 dark:hover:border-gray-400 cursor-pointer' : 'cursor-default'}`}
>
{item.completed && (
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
{editingItem?.date === date && editingItem?.index === index ? (
<textarea
ref={editInputRef}
value={editContent}
onChange={(e) => {
setEditContent(e.target.value)
adjustTextAreaHeight(e.target)
}}
onKeyDown={handleEditKeyDown}
onBlur={handleSaveEdit}
autoFocus
rows={1}
className="flex-1 px-2 bg-transparent border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-gray-400 text-gray-800 dark:text-gray-200 resize-none overflow-hidden align-top"
style={{ height: '1.75rem' }}
/>
) : (
<span
onDoubleClick={() => canEdit && !item.completed && handleStartEdit(date, index, item.content)}
className={`flex-1 px-2 ${canEdit ? 'cursor-pointer' : 'cursor-default'}
} ${item.completed
? 'text-gray-400 dark:text-gray-500 line-through'
: 'text-gray-700 dark:text-gray-200'
}`}
>
{item.content}
</span>
)}
{canEdit && !item.completed && (
<button
onClick={() => handleDeleteTodo(date, index)}
className="p-1 opacity-0 group-hover:opacity-100 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-all text-red-500"
>
<Trash2 size={14} />
</button>
)}
</div>
))}
{canEdit && addingDate === date && (
<div className="flex items-center gap-3">
<div className="w-4 h-4 rounded-full border-2 border-gray-400 dark:border-gray-500 shrink-0" />
<textarea
ref={inputRef}
value={newTodoContent}
onChange={(e) => {
setNewTodoContent(e.target.value)
adjustTextAreaHeight(e.target)
}}
onKeyDown={(e) => handleKeyDown(e, date)}
onBlur={() => {
handleAddTodo(date, false)
}}
placeholder="输入任务内容..."
rows={1}
className="flex-1 px-2 bg-transparent border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-gray-400 text-gray-800 dark:text-gray-200 placeholder-gray-400 resize-none overflow-hidden align-top"
style={{ height: '1.75rem' }}
/>
</div>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
}