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

View File

@@ -0,0 +1,327 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, ChevronDown, ChevronUp, Calendar } from 'lucide-react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { tasksApi } from '../api/tasks'
import clsx from 'clsx'
import { format, addDays } from 'date-fns'
const COLORS = [
'#6B7280', '#6366f1', '#8b5cf6', '#d946ef', '#ec4899',
'#f43f5e', '#f97316', '#eab308', '#22c55e', '#0ea5e9',
]
const ICON_CATEGORIES = [
{ name: 'Продуктивность', icons: ['📋', '📝', '✅', '📌', '🎯', '💡', '📅', '⏰'] },
{ name: 'Работа', icons: ['💼', '💻', '📧', '📞', '📊', '📈', '🖥️', '⌨️'] },
{ name: 'Дом', icons: ['🏠', '🧹', '🧺', '🍳', '🛒', '🔧', '🪴', '🛋️'] },
{ name: 'Финансы', icons: ['💰', '💳', '📊', '💵', '🏦', '🧾'] },
{ name: 'Здоровье', icons: ['💊', '🏃', '🧘', '💪', '🩺', '🦷'] },
{ name: 'Разное', icons: ['⭐', '🎁', '📦', '✈️', '🚗', '📷', '🎉'] },
]
const PRIORITIES = [
{ value: 0, label: 'Без приоритета', color: 'bg-gray-100 text-gray-600' },
{ value: 1, label: 'Низкий', color: 'bg-blue-100 text-blue-700' },
{ value: 2, label: 'Средний', color: 'bg-yellow-100 text-yellow-700' },
{ value: 3, label: 'Высокий', color: 'bg-red-100 text-red-700' },
]
export default function CreateTaskModal({ open, onClose, defaultDueDate = null }) {
const today = format(new Date(), 'yyyy-MM-dd')
const tomorrow = format(addDays(new Date(), 1), 'yyyy-MM-dd')
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [color, setColor] = useState(COLORS[0])
const [icon, setIcon] = useState('📋')
const [dueDate, setDueDate] = useState(defaultDueDate || today)
const [priority, setPriority] = useState(0)
const [error, setError] = useState('')
const [showAllIcons, setShowAllIcons] = useState(false)
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (data) => tasksApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
queryClient.invalidateQueries({ queryKey: ['tasks-today'] })
handleClose()
},
onError: (err) => {
setError(err.response?.data?.error || 'Ошибка создания')
},
})
const handleClose = () => {
setTitle('')
setDescription('')
setColor(COLORS[0])
setIcon('📋')
setDueDate(defaultDueDate || today)
setPriority(0)
setError('')
setShowAllIcons(false)
onClose()
}
const handleSubmit = (e) => {
e.preventDefault()
if (!title.trim()) {
setError('Введи название задачи')
return
}
mutation.mutate({
title,
description,
color,
icon,
due_date: dueDate || null,
priority,
})
}
const popularIcons = ['📋', '📝', '✅', '🎯', '💼', '🏠', '💰', '📞']
return (
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={handleClose}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40"
/>
<motion.div
initial={{ opacity: 0, y: 100 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 100 }}
className="fixed bottom-0 left-0 right-0 z-50 p-4 sm:p-0 sm:flex sm:items-center sm:justify-center sm:inset-0"
>
<div className="bg-white rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
<div className="sticky top-0 bg-white border-b border-gray-100 px-4 py-3 flex items-center justify-between">
<h2 className="text-lg font-semibold">Новая задача</h2>
<button
onClick={handleClose}
className="p-2 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100"
>
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
{error && (
<div className="p-3 rounded-xl bg-red-50 text-red-600 text-sm">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Название
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="input"
placeholder="Что нужно сделать?"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Описание (опционально)
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="input min-h-[80px] resize-none"
placeholder="Подробности задачи..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Срок выполнения
</label>
<div className="flex gap-2 mb-2">
<button
type="button"
onClick={() => setDueDate(today)}
className={clsx(
'px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
dueDate === today
? 'bg-primary-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
)}
>
Сегодня
</button>
<button
type="button"
onClick={() => setDueDate(tomorrow)}
className={clsx(
'px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
dueDate === tomorrow
? 'bg-primary-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
)}
>
Завтра
</button>
<button
type="button"
onClick={() => setDueDate('')}
className={clsx(
'px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
!dueDate
? 'bg-primary-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
)}
>
Без срока
</button>
</div>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="input pl-10"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Приоритет
</label>
<div className="flex gap-2 flex-wrap">
{PRIORITIES.map((p) => (
<button
key={p.value}
type="button"
onClick={() => setPriority(p.value)}
className={clsx(
'px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
priority === p.value
? p.color + ' ring-2 ring-offset-1 ring-gray-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
)}
>
{p.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Иконка
</label>
<div className="flex flex-wrap gap-2">
{popularIcons.map((ic) => (
<button
key={ic}
type="button"
onClick={() => setIcon(ic)}
className={clsx(
'w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all',
icon === ic
? 'bg-primary-100 ring-2 ring-primary-500'
: 'bg-gray-100 hover:bg-gray-200'
)}
>
{ic}
</button>
))}
</div>
<button
type="button"
onClick={() => setShowAllIcons(!showAllIcons)}
className="mt-2 flex items-center gap-1 text-sm text-primary-600 hover:text-primary-700"
>
{showAllIcons ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
{showAllIcons ? 'Скрыть' : 'Все иконки'}
</button>
<AnimatePresence>
{showAllIcons && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mt-3 space-y-3"
>
{ICON_CATEGORIES.map((category) => (
<div key={category.name}>
<p className="text-xs text-gray-500 mb-1.5">{category.name}</p>
<div className="flex flex-wrap gap-1.5">
{category.icons.map((ic) => (
<button
key={ic}
type="button"
onClick={() => setIcon(ic)}
className={clsx(
'w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all',
icon === ic
? 'bg-primary-100 ring-2 ring-primary-500'
: 'bg-gray-100 hover:bg-gray-200'
)}
>
{ic}
</button>
))}
</div>
</div>
))}
</motion.div>
)}
</AnimatePresence>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Цвет
</label>
<div className="flex flex-wrap gap-2">
{COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={clsx(
'w-8 h-8 rounded-full transition-all',
color === c ? 'ring-2 ring-offset-2 ring-gray-400 scale-110' : ''
)}
style={{ backgroundColor: c }}
/>
))}
</div>
</div>
<div className="pt-2">
<button
type="submit"
disabled={mutation.isPending}
className="btn btn-primary w-full"
>
{mutation.isPending ? 'Создаём...' : 'Создать задачу'}
</button>
</div>
</form>
</div>
</motion.div>
</>
)}
</AnimatePresence>
)
}