Initial commit: Pulse web app

This commit is contained in:
Cosmo
2026-02-06 11:19:55 +00:00
commit 199887e552
31 changed files with 4314 additions and 0 deletions

317
src/pages/Tasks.jsx Normal file
View File

@@ -0,0 +1,317 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Plus, Check, Circle, Calendar, AlertTriangle, Undo2, Edit2 } from 'lucide-react'
import { format, parseISO, isToday, isTomorrow, isPast } from 'date-fns'
import { ru } from 'date-fns/locale'
import { tasksApi } from '../api/tasks'
import Navigation from '../components/Navigation'
import CreateTaskModal from '../components/CreateTaskModal'
import EditTaskModal from '../components/EditTaskModal'
import clsx from 'clsx'
const PRIORITY_LABELS = {
0: null,
1: { label: 'Низкий', class: 'bg-blue-100 text-blue-700' },
2: { label: 'Средний', class: 'bg-yellow-100 text-yellow-700' },
3: { label: 'Высокий', class: 'bg-red-100 text-red-700' },
}
function formatDueDate(dateStr) {
if (!dateStr) return null
const date = parseISO(dateStr)
if (isToday(date)) return 'Сегодня'
if (isTomorrow(date)) return 'Завтра'
return format(date, 'd MMM', { locale: ru })
}
export default function Tasks() {
const [showCreate, setShowCreate] = useState(false)
const [editingTask, setEditingTask] = useState(null)
const [filter, setFilter] = useState('active') // all, active, completed
const queryClient = useQueryClient()
const { data: tasks = [], isLoading } = useQuery({
queryKey: ['tasks', filter],
queryFn: () => {
if (filter === 'all') return tasksApi.list()
return tasksApi.list(filter === 'completed')
},
})
const completeMutation = useMutation({
mutationFn: (id) => tasksApi.complete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
queryClient.invalidateQueries({ queryKey: ['tasks-today'] })
},
})
const uncompleteMutation = useMutation({
mutationFn: (id) => tasksApi.uncomplete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
queryClient.invalidateQueries({ queryKey: ['tasks-today'] })
},
})
const handleToggle = (task) => {
if (task.completed) {
uncompleteMutation.mutate(task.id)
} else {
completeMutation.mutate(task.id)
}
}
const activeTasks = tasks.filter(t => !t.completed)
const completedTasks = tasks.filter(t => t.completed)
return (
<div className="min-h-screen bg-surface-50 gradient-mesh pb-24">
<header className="bg-white/70 backdrop-blur-xl border-b border-gray-100/50 sticky top-0 z-10">
<div className="max-w-lg mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-display font-bold text-gray-900">Задачи</h1>
<button
onClick={() => setShowCreate(true)}
className="p-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-colors shadow-lg shadow-primary-500/30"
>
<Plus size={22} />
</button>
</div>
{/* Фильтры */}
<div className="flex gap-2 mt-4">
{[
{ key: 'active', label: 'Активные' },
{ key: 'completed', label: 'Выполненные' },
{ key: 'all', label: 'Все' },
].map(({ key, label }) => (
<button
key={key}
onClick={() => setFilter(key)}
className={clsx(
'px-4 py-2 rounded-xl text-sm font-medium transition-all',
filter === key
? 'bg-primary-500 text-white shadow-lg shadow-primary-500/30'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
)}
>
{label}
</button>
))}
</div>
</div>
</header>
<main className="max-w-lg mx-auto px-4 py-6">
{isLoading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="card p-5 animate-pulse">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-gray-200" />
<div className="flex-1">
<div className="h-5 bg-gray-200 rounded-lg w-3/4 mb-2" />
<div className="h-4 bg-gray-200 rounded-lg w-1/4" />
</div>
</div>
</div>
))}
</div>
) : tasks.length === 0 ? (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="card p-10 text-center"
>
<div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-primary-100 to-primary-200 flex items-center justify-center mx-auto mb-5">
<Check className="w-10 h-10 text-primary-600" />
</div>
<h3 className="text-xl font-display font-bold text-gray-900 mb-2">
{filter === 'active' ? 'Нет активных задач' : filter === 'completed' ? 'Нет выполненных задач' : 'Нет задач'}
</h3>
<p className="text-gray-500 mb-6">
{filter === 'active' ? 'Добавь новую задачу или выбери другой фильтр' : 'Выполняй задачи и они появятся здесь'}
</p>
{filter === 'active' && (
<button
onClick={() => setShowCreate(true)}
className="btn btn-primary"
>
<Plus size={18} />
Добавить задачу
</button>
)}
</motion.div>
) : (
<div className="space-y-4">
<AnimatePresence>
{tasks.map((task, index) => (
<TaskCard
key={task.id}
task={task}
index={index}
onToggle={() => handleToggle(task)}
onEdit={() => setEditingTask(task)}
isLoading={completeMutation.isPending || uncompleteMutation.isPending}
/>
))}
</AnimatePresence>
</div>
)}
</main>
<Navigation />
<CreateTaskModal
open={showCreate}
onClose={() => setShowCreate(false)}
/>
<EditTaskModal
open={!!editingTask}
onClose={() => setEditingTask(null)}
task={editingTask}
/>
</div>
)
}
function TaskCard({ task, index, onToggle, onEdit, isLoading }) {
const [showConfetti, setShowConfetti] = useState(false)
const priorityInfo = PRIORITY_LABELS[task.priority]
const dueDateLabel = formatDueDate(task.due_date)
const isOverdue = task.due_date && isPast(parseISO(task.due_date)) && !isToday(parseISO(task.due_date)) && !task.completed
const handleCheck = (e) => {
e.stopPropagation()
if (isLoading) return
if (!task.completed) {
setShowConfetti(true)
setTimeout(() => setShowConfetti(false), 1000)
}
onToggle()
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -100 }}
transition={{ delay: index * 0.05 }}
className="card p-4 relative overflow-hidden"
>
{showConfetti && (
<motion.div
initial={{ opacity: 1 }}
animate={{ opacity: 0 }}
transition={{ duration: 1 }}
className="absolute inset-0 pointer-events-none"
>
{[...Array(6)].map((_, i) => (
<motion.div
key={i}
initial={{ x: '50%', y: '50%', scale: 0 }}
animate={{
x: `${Math.random() * 100}%`,
y: `${Math.random() * 100}%`,
scale: [0, 1, 0]
}}
transition={{ duration: 0.6, delay: i * 0.05 }}
className="absolute w-2 h-2 rounded-full"
style={{ backgroundColor: task.color }}
/>
))}
</motion.div>
)}
<div className="flex items-start gap-3">
<motion.button
onClick={handleCheck}
disabled={isLoading}
whileTap={{ scale: 0.9 }}
className={clsx(
'w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 flex-shrink-0 mt-0.5',
task.completed
? 'bg-gradient-to-br from-green-400 to-green-500 shadow-lg shadow-green-400/30'
: 'border-2 hover:shadow-md'
)}
style={{
borderColor: task.completed ? undefined : task.color + '40',
backgroundColor: task.completed ? undefined : task.color + '10'
}}
>
{task.completed ? (
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: 'spring', stiffness: 500 }}
>
<Check className="w-5 h-5 text-white" strokeWidth={3} />
</motion.div>
) : (
<span className="text-lg">{task.icon || '📋'}</span>
)}
</motion.button>
<div className="flex-1 min-w-0" onClick={onEdit}>
<h3 className={clsx(
"font-semibold truncate cursor-pointer hover:text-primary-600",
task.completed ? "text-gray-400 line-through" : "text-gray-900"
)}>{task.title}</h3>
{task.description && (
<p className="text-sm text-gray-500 truncate mt-0.5">{task.description}</p>
)}
<div className="flex items-center gap-2 mt-2 flex-wrap">
{dueDateLabel && (
<span className={clsx(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium',
isOverdue
? 'bg-red-100 text-red-700'
: 'bg-gray-100 text-gray-600'
)}>
{isOverdue && <AlertTriangle size={12} />}
<Calendar size={12} />
{dueDateLabel}
</span>
)}
{priorityInfo && (
<span className={clsx(
'px-2 py-0.5 rounded-md text-xs font-medium',
priorityInfo.class
)}>
{priorityInfo.label}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={onEdit}
className="p-2 text-gray-400 hover:text-primary-500 hover:bg-primary-50 rounded-xl transition-all"
>
<Edit2 size={16} />
</button>
{task.completed && (
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
onClick={handleCheck}
disabled={isLoading}
className="p-2 text-gray-400 hover:text-orange-500 hover:bg-orange-50 rounded-xl transition-all"
title="Отменить"
>
<Undo2 size={16} />
</motion.button>
)}
</div>
</div>
</motion.div>
)
}