Initial commit: Pulse web app
This commit is contained in:
274
src/pages/Habits.jsx
Normal file
274
src/pages/Habits.jsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Plus, Settings, Flame, Calendar, ChevronRight, Archive, ArchiveRestore } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
import { habitsApi } from '../api/habits'
|
||||
import CreateHabitModal from '../components/CreateHabitModal'
|
||||
import EditHabitModal from '../components/EditHabitModal'
|
||||
import Navigation from '../components/Navigation'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export default function Habits() {
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [editingHabit, setEditingHabit] = useState(null)
|
||||
const [showArchived, setShowArchived] = useState(false)
|
||||
const [habitStats, setHabitStats] = useState({})
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: habits = [], isLoading } = useQuery({
|
||||
queryKey: ['habits', showArchived],
|
||||
queryFn: () => habitsApi.list().then(h => showArchived ? h : h.filter(x => !x.is_archived)),
|
||||
})
|
||||
|
||||
const { data: archivedHabits = [] } = useQuery({
|
||||
queryKey: ['habits-archived'],
|
||||
queryFn: () => habitsApi.list().then(h => h.filter(x => x.is_archived)),
|
||||
enabled: showArchived,
|
||||
})
|
||||
|
||||
// Загружаем статистику для каждой привычки
|
||||
useEffect(() => {
|
||||
if (habits.length > 0) {
|
||||
loadStats()
|
||||
}
|
||||
}, [habits])
|
||||
|
||||
const loadStats = async () => {
|
||||
const statsMap = {}
|
||||
await Promise.all(habits.map(async (habit) => {
|
||||
try {
|
||||
const stats = await habitsApi.getHabitStats(habit.id)
|
||||
statsMap[habit.id] = stats
|
||||
} catch (e) {
|
||||
console.error('Error loading stats for habit', habit.id, e)
|
||||
}
|
||||
}))
|
||||
setHabitStats(statsMap)
|
||||
}
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: ({ id, archived }) => habitsApi.update(id, { is_archived: archived }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['habits'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['habits-archived'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['stats'] })
|
||||
},
|
||||
})
|
||||
|
||||
const getFrequencyLabel = (habit) => {
|
||||
if (habit.frequency === 'daily') return 'Ежедневно'
|
||||
if (habit.frequency === 'weekly' && habit.target_days) {
|
||||
const days = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
|
||||
return habit.target_days.map(d => days[d - 1]).join(', ')
|
||||
}
|
||||
if (habit.frequency === 'custom') {
|
||||
return `Каждые ${habit.target_count} дн.`
|
||||
}
|
||||
return habit.frequency
|
||||
}
|
||||
|
||||
const activeHabits = habits.filter(h => !h.is_archived)
|
||||
const archivedList = habits.filter(h => h.is_archived)
|
||||
|
||||
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 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-display font-bold text-gray-900">Мои привычки</h1>
|
||||
<p className="text-sm text-gray-500">{activeHabits.length} активных</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Новая
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-lg mx-auto px-4 py-6 space-y-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-14 h-14 rounded-2xl bg-gray-200" />
|
||||
<div className="flex-1">
|
||||
<div className="h-5 bg-gray-200 rounded-lg w-1/2 mb-2" />
|
||||
<div className="h-4 bg-gray-200 rounded-lg w-1/3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : activeHabits.length === 0 && !showArchived ? (
|
||||
<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-accent-100 flex items-center justify-center mx-auto mb-5">
|
||||
<Plus className="w-10 h-10 text-primary-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-display font-bold text-gray-900 mb-2">Нет привычек</h3>
|
||||
<p className="text-gray-500 mb-6">Создай свою первую привычку!</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<Plus size={20} className="mr-2" />
|
||||
Создать привычку
|
||||
</button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<>
|
||||
{/* Активные привычки */}
|
||||
<div className="space-y-3">
|
||||
<AnimatePresence>
|
||||
{activeHabits.map((habit, index) => (
|
||||
<HabitListItem
|
||||
key={habit.id}
|
||||
habit={habit}
|
||||
index={index}
|
||||
stats={habitStats[habit.id]}
|
||||
frequencyLabel={getFrequencyLabel(habit)}
|
||||
onEdit={() => setEditingHabit(habit)}
|
||||
onArchive={() => archiveMutation.mutate({ id: habit.id, archived: true })}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Архивные привычки */}
|
||||
{archivedList.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<button
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
<Archive size={18} />
|
||||
<span className="font-medium">Архив ({archivedList.length})</span>
|
||||
<ChevronRight
|
||||
size={18}
|
||||
className={clsx(
|
||||
'transition-transform',
|
||||
showArchived && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showArchived && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
{archivedList.map((habit, index) => (
|
||||
<motion.div
|
||||
key={habit.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="card p-4 opacity-60"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center text-xl"
|
||||
style={{ backgroundColor: habit.color + '20' }}
|
||||
>
|
||||
{habit.icon || '✨'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-600 truncate">{habit.name}</h3>
|
||||
<p className="text-sm text-gray-400">{getFrequencyLabel(habit)}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => archiveMutation.mutate({ id: habit.id, archived: false })}
|
||||
className="p-2 text-gray-400 hover:text-green-500 hover:bg-green-50 rounded-xl transition-all"
|
||||
title="Восстановить"
|
||||
>
|
||||
<ArchiveRestore size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Navigation />
|
||||
|
||||
<CreateHabitModal
|
||||
open={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
/>
|
||||
|
||||
<EditHabitModal
|
||||
open={!!editingHabit}
|
||||
onClose={() => setEditingHabit(null)}
|
||||
habit={editingHabit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HabitListItem({ habit, index, stats, frequencyLabel, onEdit, onArchive }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, x: -100 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
onClick={onEdit}
|
||||
className="card p-4 cursor-pointer hover:shadow-lg transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-14 h-14 rounded-2xl flex items-center justify-center text-2xl flex-shrink-0"
|
||||
style={{ backgroundColor: habit.color + '15' }}
|
||||
>
|
||||
{habit.icon || '✨'}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{habit.name}</h3>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span
|
||||
className="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: habit.color + '15', color: habit.color }}
|
||||
>
|
||||
{frequencyLabel}
|
||||
</span>
|
||||
{stats && stats.current_streak > 0 && (
|
||||
<span className="text-xs text-orange-500 flex items-center gap-1">
|
||||
<Flame size={14} />
|
||||
{stats.current_streak} дн.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{stats && (
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-gray-900">{stats.this_month}</p>
|
||||
<p className="text-xs text-gray-400">в месяц</p>
|
||||
</div>
|
||||
)}
|
||||
<ChevronRight size={20} className="text-gray-300" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user