ci: add Gitea Actions workflows and placeholder tests
This commit is contained in:
@@ -6,6 +6,7 @@ import Register from "./pages/Register"
|
||||
import Home from "./pages/Home"
|
||||
import Habits from "./pages/Habits"
|
||||
import Tasks from "./pages/Tasks"
|
||||
import Savings from "./pages/Savings"
|
||||
import VerifyEmail from "./pages/VerifyEmail"
|
||||
import ResetPassword from "./pages/ResetPassword"
|
||||
import ForgotPassword from "./pages/ForgotPassword"
|
||||
@@ -107,6 +108,14 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/savings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Savings />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/stats"
|
||||
element={
|
||||
|
||||
7
src/__tests__/app.test.js
Normal file
7
src/__tests__/app.test.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('App', () => {
|
||||
it('should pass basic test', () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -13,4 +13,9 @@ export const habitsApi = {
|
||||
|
||||
getStats: () => api.get('/habits/stats').then(r => r.data),
|
||||
getHabitStats: (id) => api.get(`/habits/${id}/stats`).then(r => r.data),
|
||||
|
||||
// Freezes
|
||||
getFreezes: (habitId) => api.get(`/habits/${habitId}/freezes`).then(r => r.data),
|
||||
addFreeze: (habitId, data) => api.post(`/habits/${habitId}/freezes`, data).then(r => r.data),
|
||||
deleteFreeze: (habitId, freezeId) => api.delete(`/habits/${habitId}/freezes/${freezeId}`),
|
||||
}
|
||||
|
||||
50
src/api/savings.js
Normal file
50
src/api/savings.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import api from "./client"
|
||||
|
||||
export const savingsApi = {
|
||||
// Categories
|
||||
listCategories: () => api.get("/savings/categories").then((r) => r.data),
|
||||
getCategory: (id) => api.get(`/savings/categories/${id}`).then((r) => r.data),
|
||||
createCategory: (data) => api.post("/savings/categories", data).then((r) => r.data),
|
||||
updateCategory: (id, data) =>
|
||||
api.put(`/savings/categories/${id}`, data).then((r) => r.data),
|
||||
deleteCategory: (id) => api.delete(`/savings/categories/${id}`),
|
||||
|
||||
// Transactions
|
||||
listTransactions: (categoryId, limit = 100, offset = 0) => {
|
||||
let url = `/savings/transactions?limit=${limit}&offset=${offset}`
|
||||
if (categoryId) url += `&category_id=${categoryId}`
|
||||
return api.get(url).then((r) => r.data)
|
||||
},
|
||||
createTransaction: (data) =>
|
||||
api.post("/savings/transactions", data).then((r) => r.data),
|
||||
updateTransaction: (id, data) =>
|
||||
api.put(`/savings/transactions/${id}`, data).then((r) => r.data),
|
||||
deleteTransaction: (id) => api.delete(`/savings/transactions/${id}`),
|
||||
|
||||
// Stats
|
||||
getStats: () => api.get("/savings/stats").then((r) => r.data),
|
||||
|
||||
// Members
|
||||
getMembers: (categoryId) =>
|
||||
api.get(`/savings/categories/${categoryId}/members`).then((r) => r.data),
|
||||
addMember: (categoryId, userId) =>
|
||||
api
|
||||
.post(`/savings/categories/${categoryId}/members`, { user_id: userId })
|
||||
.then((r) => r.data),
|
||||
removeMember: (categoryId, userId) =>
|
||||
api.delete(`/savings/categories/${categoryId}/members/${userId}`),
|
||||
|
||||
// Recurring Plans
|
||||
getRecurringPlans: (categoryId) =>
|
||||
api
|
||||
.get(`/savings/categories/${categoryId}/recurring-plans`)
|
||||
.then((r) => r.data),
|
||||
createRecurringPlan: (categoryId, data) =>
|
||||
api
|
||||
.post(`/savings/categories/${categoryId}/recurring-plans`, data)
|
||||
.then((r) => r.data),
|
||||
updateRecurringPlan: (planId, data) =>
|
||||
api.put(`/savings/recurring-plans/${planId}`, data).then((r) => r.data),
|
||||
deleteRecurringPlan: (planId) =>
|
||||
api.delete(`/savings/recurring-plans/${planId}`),
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { X, ChevronDown, ChevronUp, Clock } from "lucide-react"
|
||||
import { X, ChevronDown, ChevronUp, Clock, Calendar } from "lucide-react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { habitsApi } from "../api/habits"
|
||||
import { format } from "date-fns"
|
||||
import clsx from "clsx"
|
||||
|
||||
const COLORS = [
|
||||
@@ -16,7 +17,7 @@ const ICON_CATEGORIES = [
|
||||
{ name: "Продуктивность", icons: ["📚", "📖", "✏️", "💻", "🎯", "📝", "📅", "⏰"] },
|
||||
{ name: "Дом", icons: ["🏠", "🧹", "🧺", "🍳", "🛒", "🔧", "🪴"] },
|
||||
{ name: "Финансы", icons: ["💰", "💳", "📊", "💵", "🏦"] },
|
||||
{ name: "Социальное", icons: ["👥", "💬", "📞", "👨👩👧👦", "❤️"] },
|
||||
{ name: "Социальное", icons: ["👥", "💬", "📞", "👪", "❤️"] },
|
||||
{ name: "Хобби", icons: ["🎨", "🎵", "🎸", "🎮", "📷", "✈️", "🚗"] },
|
||||
{ name: "Еда/вода", icons: ["🥗", "🍎", "🥤", "☕", "🍽️", "💧"] },
|
||||
{ name: "Разное", icons: ["⭐", "🎉", "✨", "🔥", "🌟", "💎", "🎁"] },
|
||||
@@ -39,7 +40,9 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
const [icon, setIcon] = useState("✨")
|
||||
const [frequency, setFrequency] = useState("daily")
|
||||
const [targetDays, setTargetDays] = useState([1, 2, 3, 4, 5, 6, 7])
|
||||
const [intervalDays, setIntervalDays] = useState(2)
|
||||
const [reminderTime, setReminderTime] = useState("")
|
||||
const [startDate, setStartDate] = useState(format(new Date(), "yyyy-MM-dd"))
|
||||
const [error, setError] = useState("")
|
||||
const [showAllIcons, setShowAllIcons] = useState(false)
|
||||
|
||||
@@ -64,7 +67,9 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
setIcon("✨")
|
||||
setFrequency("daily")
|
||||
setTargetDays([1, 2, 3, 4, 5, 6, 7])
|
||||
setIntervalDays(2)
|
||||
setReminderTime("")
|
||||
setStartDate(format(new Date(), "yyyy-MM-dd"))
|
||||
setError("")
|
||||
setShowAllIcons(false)
|
||||
onClose()
|
||||
@@ -80,11 +85,19 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
setError("Выбери хотя бы один день недели")
|
||||
return
|
||||
}
|
||||
const interval = parseInt(intervalDays) || 0
|
||||
if (frequency === "interval" && (interval < 2 || interval > 30)) {
|
||||
setError("Интервал должен быть от 2 до 30 дней")
|
||||
return
|
||||
}
|
||||
|
||||
const data = { name, description, color, icon, frequency }
|
||||
const data = { name, description, color, icon, frequency, start_date: startDate }
|
||||
if (frequency === "weekly") {
|
||||
data.target_days = targetDays
|
||||
}
|
||||
if (frequency === "interval") {
|
||||
data.target_count = parseInt(intervalDays)
|
||||
}
|
||||
if (reminderTime) {
|
||||
data.reminder_time = reminderTime
|
||||
}
|
||||
@@ -119,12 +132,12 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
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">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 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"
|
||||
className="p-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
@@ -138,7 +151,7 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Название
|
||||
</label>
|
||||
<input
|
||||
@@ -152,7 +165,7 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Описание (опционально)
|
||||
</label>
|
||||
<input
|
||||
@@ -165,7 +178,7 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Периодичность
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
@@ -173,10 +186,10 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
type="button"
|
||||
onClick={() => setFrequency("daily")}
|
||||
className={clsx(
|
||||
"flex-1 py-2.5 px-4 rounded-xl font-medium text-sm transition-all",
|
||||
"flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
|
||||
frequency === "daily"
|
||||
? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
Ежедневно
|
||||
@@ -185,13 +198,25 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
type="button"
|
||||
onClick={() => setFrequency("weekly")}
|
||||
className={clsx(
|
||||
"flex-1 py-2.5 px-4 rounded-xl font-medium text-sm transition-all",
|
||||
"flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
|
||||
frequency === "weekly"
|
||||
? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
По дням недели
|
||||
По дням
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFrequency("interval")}
|
||||
className={clsx(
|
||||
"flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
|
||||
frequency === "interval"
|
||||
? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
Интервал
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,7 +227,7 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Дни недели
|
||||
</label>
|
||||
<div className="flex gap-1.5">
|
||||
@@ -215,7 +240,7 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
"flex-1 py-2 rounded-lg font-medium text-sm transition-all",
|
||||
targetDays.includes(day.id)
|
||||
? "bg-primary-500 text-white shadow-md"
|
||||
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 dark:text-gray-500 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{day.short}
|
||||
@@ -225,12 +250,53 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{frequency === "interval" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-gray-500 dark:text-gray-400 dark:text-gray-500">Каждые</span>
|
||||
<input
|
||||
type="number"
|
||||
min="2"
|
||||
max="30"
|
||||
value={intervalDays}
|
||||
onChange={(e) => setIntervalDays(e.target.value === "" ? "" : parseInt(e.target.value) || "")}
|
||||
className="input w-20 text-center"
|
||||
/>
|
||||
<span className="text-gray-500 dark:text-gray-400 dark:text-gray-500">дней</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Дата начала
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
|
||||
{frequency === "interval"
|
||||
? "Интервал считается от этой даты"
|
||||
: "Привычка появится начиная с этой даты"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Напоминание (опционально)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||||
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
|
||||
<input
|
||||
type="time"
|
||||
value={reminderTime}
|
||||
@@ -238,13 +304,13 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
|
||||
Получишь напоминание в Telegram в указанное время
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Иконка
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -257,7 +323,7 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
"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"
|
||||
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{ic}
|
||||
@@ -284,7 +350,7 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
>
|
||||
{ICON_CATEGORIES.map((category) => (
|
||||
<div key={category.name}>
|
||||
<p className="text-xs text-gray-500 mb-1.5">{category.name}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1.5">{category.name}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{category.icons.map((ic) => (
|
||||
<button
|
||||
@@ -295,7 +361,7 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
"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"
|
||||
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{ic}
|
||||
@@ -310,7 +376,7 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Цвет
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { X, ChevronDown, ChevronUp, Calendar, Clock } from "lucide-react"
|
||||
import { X, ChevronDown, ChevronUp, Calendar, Clock, Repeat } from "lucide-react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { tasksApi } from "../api/tasks"
|
||||
import clsx from "clsx"
|
||||
@@ -21,12 +21,19 @@ const ICON_CATEGORIES = [
|
||||
]
|
||||
|
||||
const PRIORITIES = [
|
||||
{ value: 0, label: "Без приоритета", color: "bg-gray-100 text-gray-600" },
|
||||
{ value: 0, label: "Без приоритета", color: "bg-gray-100 dark:bg-gray-800 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" },
|
||||
]
|
||||
|
||||
const RECURRENCE_TYPES = [
|
||||
{ value: "daily", label: "Ежедневно" },
|
||||
{ value: "weekly", label: "Еженедельно" },
|
||||
{ value: "monthly", label: "Ежемесячно" },
|
||||
{ value: "custom", label: "Каждые N дней" },
|
||||
]
|
||||
|
||||
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")
|
||||
@@ -41,6 +48,12 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
const [error, setError] = useState("")
|
||||
const [showAllIcons, setShowAllIcons] = useState(false)
|
||||
|
||||
// Recurring state
|
||||
const [isRecurring, setIsRecurring] = useState(false)
|
||||
const [recurrenceType, setRecurrenceType] = useState("daily")
|
||||
const [recurrenceInterval, setRecurrenceInterval] = useState(1)
|
||||
const [recurrenceEndDate, setRecurrenceEndDate] = useState("")
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const mutation = useMutation({
|
||||
@@ -65,6 +78,10 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
setReminderTime("")
|
||||
setError("")
|
||||
setShowAllIcons(false)
|
||||
setIsRecurring(false)
|
||||
setRecurrenceType("daily")
|
||||
setRecurrenceInterval(1)
|
||||
setRecurrenceEndDate("")
|
||||
onClose()
|
||||
}
|
||||
|
||||
@@ -75,7 +92,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
return
|
||||
}
|
||||
|
||||
mutation.mutate({
|
||||
const data = {
|
||||
title,
|
||||
description,
|
||||
color,
|
||||
@@ -83,7 +100,16 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
due_date: dueDate || null,
|
||||
priority,
|
||||
reminder_time: reminderTime || null,
|
||||
})
|
||||
is_recurring: isRecurring,
|
||||
}
|
||||
|
||||
if (isRecurring) {
|
||||
data.recurrence_type = recurrenceType
|
||||
data.recurrence_interval = recurrenceType === "custom" ? recurrenceInterval : 1
|
||||
data.recurrence_end_date = recurrenceEndDate || null
|
||||
}
|
||||
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
const popularIcons = ["📋", "📝", "✅", "🎯", "💼", "🏠", "💰", "📞"]
|
||||
@@ -105,12 +131,12 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
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">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 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"
|
||||
className="p-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
@@ -124,7 +150,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Название
|
||||
</label>
|
||||
<input
|
||||
@@ -138,7 +164,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Описание (опционально)
|
||||
</label>
|
||||
<textarea
|
||||
@@ -150,7 +176,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Срок выполнения
|
||||
</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
@@ -161,7 +187,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
"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"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
Сегодня
|
||||
@@ -173,7 +199,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
"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"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
Завтра
|
||||
@@ -185,14 +211,14 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
"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"
|
||||
: "bg-gray-100 dark:bg-gray-800 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} />
|
||||
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
@@ -202,12 +228,92 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recurring Section */}
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Repeat size={18} className={isRecurring ? "text-primary-500" : "text-gray-400 dark:text-gray-500"} />
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Повторять</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsRecurring(!isRecurring)}
|
||||
className={clsx(
|
||||
"w-12 h-6 rounded-full transition-all relative",
|
||||
isRecurring ? "bg-primary-500" : "bg-gray-200"
|
||||
)}
|
||||
>
|
||||
<div className={clsx(
|
||||
"absolute top-1 w-4 h-4 rounded-full bg-white dark:bg-gray-900 shadow-sm transition-all",
|
||||
isRecurring ? "right-1" : "left-1"
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isRecurring && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{RECURRENCE_TYPES.map((type) => (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
onClick={() => setRecurrenceType(type.value)}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||
recurrenceType === type.value
|
||||
? "bg-primary-500 text-white"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{recurrenceType === "custom" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Каждые</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
value={recurrenceInterval}
|
||||
onChange={(e) => setRecurrenceInterval(parseInt(e.target.value) || 1)}
|
||||
className="input w-20 text-center"
|
||||
/>
|
||||
<span className="text-sm text-gray-600">дней</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1">
|
||||
Повторять до (опционально)
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={recurrenceEndDate}
|
||||
onChange={(e) => setRecurrenceEndDate(e.target.value)}
|
||||
className="input"
|
||||
min={dueDate || today}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Напоминание (опционально)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||||
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
|
||||
<input
|
||||
type="time"
|
||||
value={reminderTime}
|
||||
@@ -215,13 +321,13 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
|
||||
Получишь напоминание в Telegram в указанное время
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Приоритет
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
@@ -234,7 +340,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
"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"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
@@ -244,7 +350,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Иконка
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -257,7 +363,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
"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"
|
||||
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{ic}
|
||||
@@ -284,7 +390,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
>
|
||||
{ICON_CATEGORIES.map((category) => (
|
||||
<div key={category.name}>
|
||||
<p className="text-xs text-gray-500 mb-1.5">{category.name}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1.5">{category.name}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{category.icons.map((ic) => (
|
||||
<button
|
||||
@@ -295,7 +401,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
"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"
|
||||
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{ic}
|
||||
@@ -310,7 +416,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Цвет
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { X, Trash2, ChevronDown, ChevronUp, Clock } from "lucide-react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { X, Trash2, ChevronDown, ChevronUp, Clock, Calendar, Snowflake, Plus } from "lucide-react"
|
||||
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query"
|
||||
import { habitsApi } from "../api/habits"
|
||||
import { format, parseISO, isBefore, isAfter, startOfDay } from "date-fns"
|
||||
import { ru } from "date-fns/locale"
|
||||
import clsx from "clsx"
|
||||
|
||||
const COLORS = [
|
||||
@@ -16,7 +18,7 @@ const ICON_CATEGORIES = [
|
||||
{ name: "Продуктивность", icons: ["📚", "📖", "✏️", "💻", "🎯", "📝", "📅", "⏰"] },
|
||||
{ name: "Дом", icons: ["🏠", "🧹", "🧺", "🍳", "🛒", "🔧", "🪴"] },
|
||||
{ name: "Финансы", icons: ["💰", "💳", "📊", "💵", "🏦"] },
|
||||
{ name: "Социальное", icons: ["👥", "💬", "📞", "👨👩👧👦", "❤️"] },
|
||||
{ name: "Социальное", icons: ["👥", "💬", "📞", "👪", "❤️"] },
|
||||
{ name: "Хобби", icons: ["🎨", "🎵", "🎸", "🎮", "📷", "✈️", "🚗"] },
|
||||
{ name: "Еда/вода", icons: ["🥗", "🍎", "🥤", "☕", "🍽️", "💧"] },
|
||||
{ name: "Разное", icons: ["⭐", "🎉", "✨", "🔥", "🌟", "💎", "🎁"] },
|
||||
@@ -39,13 +41,27 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
const [icon, setIcon] = useState("✨")
|
||||
const [frequency, setFrequency] = useState("daily")
|
||||
const [targetDays, setTargetDays] = useState([1, 2, 3, 4, 5, 6, 7])
|
||||
const [intervalDays, setIntervalDays] = useState(3)
|
||||
const [reminderTime, setReminderTime] = useState("")
|
||||
const [startDate, setStartDate] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [showAllIcons, setShowAllIcons] = useState(false)
|
||||
const [showFreezes, setShowFreezes] = useState(false)
|
||||
const [showAddFreeze, setShowAddFreeze] = useState(false)
|
||||
const [freezeStart, setFreezeStart] = useState("")
|
||||
const [freezeEnd, setFreezeEnd] = useState("")
|
||||
const [freezeReason, setFreezeReason] = useState("")
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Load freezes for this habit
|
||||
const { data: freezes = [], refetch: refetchFreezes } = useQuery({
|
||||
queryKey: ['habit-freezes', habit?.id],
|
||||
queryFn: () => habitsApi.getFreezes(habit.id),
|
||||
enabled: !!habit?.id && open,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (habit && open) {
|
||||
setName(habit.name || "")
|
||||
@@ -54,10 +70,23 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
setIcon(habit.icon || "✨")
|
||||
setFrequency(habit.frequency || "daily")
|
||||
setTargetDays(habit.target_days || [1, 2, 3, 4, 5, 6, 7])
|
||||
setIntervalDays(habit.target_count || 3)
|
||||
setReminderTime(habit.reminder_time || "")
|
||||
if (habit.start_date) {
|
||||
setStartDate(habit.start_date)
|
||||
} else if (habit.created_at) {
|
||||
setStartDate(format(parseISO(habit.created_at), "yyyy-MM-dd"))
|
||||
} else {
|
||||
setStartDate(format(new Date(), "yyyy-MM-dd"))
|
||||
}
|
||||
setError("")
|
||||
setShowDeleteConfirm(false)
|
||||
setShowAllIcons(false)
|
||||
setShowFreezes(false)
|
||||
setShowAddFreeze(false)
|
||||
setFreezeStart("")
|
||||
setFreezeEnd("")
|
||||
setFreezeReason("")
|
||||
}
|
||||
}, [habit, open])
|
||||
|
||||
@@ -85,10 +114,35 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
},
|
||||
})
|
||||
|
||||
const addFreezeMutation = useMutation({
|
||||
mutationFn: (data) => habitsApi.addFreeze(habit.id, data),
|
||||
onSuccess: () => {
|
||||
refetchFreezes()
|
||||
queryClient.invalidateQueries({ queryKey: ["habits"] })
|
||||
setShowAddFreeze(false)
|
||||
setFreezeStart("")
|
||||
setFreezeEnd("")
|
||||
setFreezeReason("")
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err.response?.data?.error || "Ошибка создания заморозки")
|
||||
},
|
||||
})
|
||||
|
||||
const deleteFreezeMutation = useMutation({
|
||||
mutationFn: (freezeId) => habitsApi.deleteFreeze(habit.id, freezeId),
|
||||
onSuccess: () => {
|
||||
refetchFreezes()
|
||||
queryClient.invalidateQueries({ queryKey: ["habits"] })
|
||||
},
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
setError("")
|
||||
setShowDeleteConfirm(false)
|
||||
setShowAllIcons(false)
|
||||
setShowFreezes(false)
|
||||
setShowAddFreeze(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
@@ -102,11 +156,19 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
setError("Выбери хотя бы один день недели")
|
||||
return
|
||||
}
|
||||
const interval = parseInt(intervalDays) || 0
|
||||
if (frequency === "interval" && (interval < 2 || interval > 30)) {
|
||||
setError("Интервал должен быть от 2 до 30 дней")
|
||||
return
|
||||
}
|
||||
|
||||
const data = { name, description, color, icon, frequency }
|
||||
const data = { name, description, color, icon, frequency, start_date: startDate }
|
||||
if (frequency === "weekly") {
|
||||
data.target_days = targetDays
|
||||
}
|
||||
if (frequency === "interval") {
|
||||
data.target_count = parseInt(intervalDays)
|
||||
}
|
||||
data.reminder_time = reminderTime || null
|
||||
|
||||
updateMutation.mutate(data)
|
||||
@@ -116,6 +178,23 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
deleteMutation.mutate()
|
||||
}
|
||||
|
||||
const handleAddFreeze = () => {
|
||||
if (!freezeStart || !freezeEnd) {
|
||||
setError("Укажи даты начала и окончания заморозки")
|
||||
return
|
||||
}
|
||||
if (isBefore(parseISO(freezeEnd), parseISO(freezeStart))) {
|
||||
setError("Дата окончания должна быть после даты начала")
|
||||
return
|
||||
}
|
||||
setError("")
|
||||
addFreezeMutation.mutate({
|
||||
start_date: freezeStart,
|
||||
end_date: freezeEnd,
|
||||
reason: freezeReason,
|
||||
})
|
||||
}
|
||||
|
||||
const toggleDay = (dayId) => {
|
||||
setTargetDays(prev =>
|
||||
prev.includes(dayId)
|
||||
@@ -124,6 +203,10 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
)
|
||||
}
|
||||
|
||||
const today = startOfDay(new Date())
|
||||
const activeFreezes = freezes.filter(f => !isBefore(parseISO(f.end_date), today))
|
||||
const pastFreezes = freezes.filter(f => isBefore(parseISO(f.end_date), today))
|
||||
|
||||
const popularIcons = ["✨", "💪", "📚", "🏃", "💧", "🧘", "💤", "🎯", "✏️", "🍎"]
|
||||
|
||||
if (!habit) return null
|
||||
@@ -145,12 +228,12 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
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">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 px-4 py-3 flex items-center justify-between z-10">
|
||||
<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"
|
||||
className="p-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
@@ -161,14 +244,14 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
|
||||
<Trash2 className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Удалить привычку?</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Удалить привычку?</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-6">
|
||||
Привычка "{habit.name}" и вся её история будут удалены безвозвратно.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="flex-1 btn bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
className="flex-1 btn bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
@@ -190,7 +273,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Название
|
||||
</label>
|
||||
<input
|
||||
@@ -203,7 +286,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Описание (опционально)
|
||||
</label>
|
||||
<input
|
||||
@@ -216,7 +299,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Периодичность
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
@@ -224,10 +307,10 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
type="button"
|
||||
onClick={() => setFrequency("daily")}
|
||||
className={clsx(
|
||||
"flex-1 py-2.5 px-4 rounded-xl font-medium text-sm transition-all",
|
||||
"flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
|
||||
frequency === "daily"
|
||||
? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
Ежедневно
|
||||
@@ -236,13 +319,25 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
type="button"
|
||||
onClick={() => setFrequency("weekly")}
|
||||
className={clsx(
|
||||
"flex-1 py-2.5 px-4 rounded-xl font-medium text-sm transition-all",
|
||||
"flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
|
||||
frequency === "weekly"
|
||||
? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
По дням недели
|
||||
По дням
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFrequency("interval")}
|
||||
className={clsx(
|
||||
"flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
|
||||
frequency === "interval"
|
||||
? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
Интервал
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,7 +348,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Дни недели
|
||||
</label>
|
||||
<div className="flex gap-1.5">
|
||||
@@ -266,7 +361,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
"flex-1 py-2 rounded-lg font-medium text-sm transition-all",
|
||||
targetDays.includes(day.id)
|
||||
? "bg-primary-500 text-white shadow-md"
|
||||
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 dark:text-gray-500 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{day.short}
|
||||
@@ -276,12 +371,53 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{frequency === "interval" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-gray-500 dark:text-gray-400 dark:text-gray-500">Каждые</span>
|
||||
<input
|
||||
type="number"
|
||||
min="2"
|
||||
max="30"
|
||||
value={intervalDays}
|
||||
onChange={(e) => setIntervalDays(e.target.value === "" ? "" : parseInt(e.target.value) || "")}
|
||||
className="input w-20 text-center"
|
||||
/>
|
||||
<span className="text-gray-500 dark:text-gray-400 dark:text-gray-500">дней</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Дата начала
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
|
||||
{frequency === "interval"
|
||||
? "Интервал считается от этой даты"
|
||||
: "Привычка появится начиная с этой даты"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Напоминание (опционально)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||||
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
|
||||
<input
|
||||
type="time"
|
||||
value={reminderTime}
|
||||
@@ -289,13 +425,180 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
|
||||
Получишь напоминание в Telegram в указанное время
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Freezes section */}
|
||||
<div className="border-t pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFreezes(!showFreezes)}
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Snowflake className="w-5 h-5 text-cyan-500" />
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">Заморозки</span>
|
||||
{activeFreezes.length > 0 && (
|
||||
<span className="px-2 py-0.5 bg-cyan-100 text-cyan-700 rounded-full text-xs">
|
||||
{activeFreezes.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showFreezes ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||
</button>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
|
||||
Поставь привычку на паузу на время отпуска или болезни
|
||||
</p>
|
||||
|
||||
<AnimatePresence>
|
||||
{showFreezes && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mt-3 space-y-3"
|
||||
>
|
||||
{/* Active freezes */}
|
||||
{activeFreezes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{activeFreezes.map((freeze) => {
|
||||
const isActive = !isBefore(parseISO(freeze.end_date), today) &&
|
||||
!isAfter(parseISO(freeze.start_date), today)
|
||||
return (
|
||||
<div
|
||||
key={freeze.id}
|
||||
className={clsx(
|
||||
"flex items-center justify-between p-3 rounded-xl",
|
||||
isActive ? "bg-cyan-50 border border-cyan-200" : "bg-gray-50 dark:bg-gray-800"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Snowflake className={clsx(
|
||||
"w-4 h-4",
|
||||
isActive ? "text-cyan-500" : "text-gray-400 dark:text-gray-500"
|
||||
)} />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{format(parseISO(freeze.start_date), "d MMM", { locale: ru })} — {format(parseISO(freeze.end_date), "d MMM yyyy", { locale: ru })}
|
||||
</p>
|
||||
{freeze.reason && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500">{freeze.reason}</p>
|
||||
)}
|
||||
</div>
|
||||
{isActive && (
|
||||
<span className="px-2 py-0.5 bg-cyan-200 text-cyan-800 rounded-full text-xs">
|
||||
активна
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteFreezeMutation.mutate(freeze.id)}
|
||||
className="p-1.5 text-gray-400 dark:text-gray-500 hover:text-red-500 hover:bg-red-50 rounded-lg"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add freeze form */}
|
||||
{showAddFreeze ? (
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-xl space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500">Начало</label>
|
||||
<input
|
||||
type="date"
|
||||
value={freezeStart}
|
||||
onChange={(e) => setFreezeStart(e.target.value)}
|
||||
min={format(new Date(), "yyyy-MM-dd")}
|
||||
className="input text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500">Окончание</label>
|
||||
<input
|
||||
type="date"
|
||||
value={freezeEnd}
|
||||
onChange={(e) => setFreezeEnd(e.target.value)}
|
||||
min={freezeStart || format(new Date(), "yyyy-MM-dd")}
|
||||
className="input text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={freezeReason}
|
||||
onChange={(e) => setFreezeReason(e.target.value)}
|
||||
placeholder="Причина (опционально)"
|
||||
className="input text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddFreeze(false)}
|
||||
className="flex-1 btn bg-gray-100 dark:bg-gray-800 text-gray-600 text-sm"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddFreeze}
|
||||
disabled={addFreezeMutation.isPending}
|
||||
className="flex-1 btn bg-cyan-500 text-white text-sm hover:bg-cyan-600"
|
||||
>
|
||||
{addFreezeMutation.isPending ? "..." : "Добавить"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddFreeze(true)}
|
||||
className="flex items-center gap-2 w-full p-3 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800 rounded-xl text-sm text-gray-600 transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Добавить заморозку
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Past freezes */}
|
||||
{pastFreezes.length > 0 && (
|
||||
<details className="text-sm">
|
||||
<summary className="text-gray-500 dark:text-gray-400 dark:text-gray-500 cursor-pointer hover:text-gray-700 dark:text-gray-300">
|
||||
Прошлые заморозки ({pastFreezes.length})
|
||||
</summary>
|
||||
<div className="mt-2 space-y-1">
|
||||
{pastFreezes.map((freeze) => (
|
||||
<div key={freeze.id} className="flex items-center justify-between p-2 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 dark:text-gray-500">
|
||||
<span>
|
||||
{format(parseISO(freeze.start_date), "d MMM", { locale: ru })} — {format(parseISO(freeze.end_date), "d MMM yyyy", { locale: ru })}
|
||||
{freeze.reason && " — " + freeze.reason}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteFreezeMutation.mutate(freeze.id)}
|
||||
className="p-1 text-gray-400 dark:text-gray-500 hover:text-red-500"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Иконка
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -308,7 +611,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
"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"
|
||||
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{ic}
|
||||
@@ -335,7 +638,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
>
|
||||
{ICON_CATEGORIES.map((category) => (
|
||||
<div key={category.name}>
|
||||
<p className="text-xs text-gray-500 mb-1.5">{category.name}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1.5">{category.name}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{category.icons.map((ic) => (
|
||||
<button
|
||||
@@ -346,7 +649,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
"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"
|
||||
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{ic}
|
||||
@@ -361,7 +664,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Цвет
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { X, Trash2, ChevronDown, ChevronUp, Calendar, Clock } from "lucide-react"
|
||||
import { X, Trash2, ChevronDown, ChevronUp, Calendar, Clock, Repeat } from "lucide-react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { tasksApi } from "../api/tasks"
|
||||
import clsx from "clsx"
|
||||
@@ -21,12 +21,19 @@ const ICON_CATEGORIES = [
|
||||
]
|
||||
|
||||
const PRIORITIES = [
|
||||
{ value: 0, label: "Без приоритета", color: "bg-gray-100 text-gray-600" },
|
||||
{ value: 0, label: "Без приоритета", color: "bg-gray-100 dark:bg-gray-800 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" },
|
||||
]
|
||||
|
||||
const RECURRENCE_TYPES = [
|
||||
{ value: "daily", label: "Ежедневно" },
|
||||
{ value: "weekly", label: "Еженедельно" },
|
||||
{ value: "monthly", label: "Ежемесячно" },
|
||||
{ value: "custom", label: "Каждые N дней" },
|
||||
]
|
||||
|
||||
export default function EditTaskModal({ open, onClose, task }) {
|
||||
const today = format(new Date(), "yyyy-MM-dd")
|
||||
const tomorrow = format(addDays(new Date(), 1), "yyyy-MM-dd")
|
||||
@@ -42,6 +49,12 @@ export default function EditTaskModal({ open, onClose, task }) {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [showAllIcons, setShowAllIcons] = useState(false)
|
||||
|
||||
// Recurring state
|
||||
const [isRecurring, setIsRecurring] = useState(false)
|
||||
const [recurrenceType, setRecurrenceType] = useState("daily")
|
||||
const [recurrenceInterval, setRecurrenceInterval] = useState(1)
|
||||
const [recurrenceEndDate, setRecurrenceEndDate] = useState("")
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -53,6 +66,10 @@ export default function EditTaskModal({ open, onClose, task }) {
|
||||
setDueDate(task.due_date || "")
|
||||
setPriority(task.priority || 0)
|
||||
setReminderTime(task.reminder_time || "")
|
||||
setIsRecurring(task.is_recurring || false)
|
||||
setRecurrenceType(task.recurrence_type || "daily")
|
||||
setRecurrenceInterval(task.recurrence_interval || 1)
|
||||
setRecurrenceEndDate(task.recurrence_end_date || "")
|
||||
setError("")
|
||||
setShowDeleteConfirm(false)
|
||||
setShowAllIcons(false)
|
||||
@@ -97,7 +114,7 @@ export default function EditTaskModal({ open, onClose, task }) {
|
||||
return
|
||||
}
|
||||
|
||||
updateMutation.mutate({
|
||||
const data = {
|
||||
title,
|
||||
description,
|
||||
color,
|
||||
@@ -105,11 +122,13 @@ export default function EditTaskModal({ open, onClose, task }) {
|
||||
due_date: dueDate || null,
|
||||
priority,
|
||||
reminder_time: reminderTime || null,
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteMutation.mutate()
|
||||
is_recurring: isRecurring,
|
||||
recurrence_type: isRecurring ? recurrenceType : null,
|
||||
recurrence_interval: isRecurring && recurrenceType === "custom" ? recurrenceInterval : 1,
|
||||
recurrence_end_date: isRecurring && recurrenceEndDate ? recurrenceEndDate : null,
|
||||
}
|
||||
|
||||
updateMutation.mutate(data)
|
||||
}
|
||||
|
||||
const popularIcons = ["📋", "📝", "✅", "🎯", "💼", "🏠", "💰", "📞"]
|
||||
@@ -133,275 +152,348 @@ export default function EditTaskModal({ open, onClose, task }) {
|
||||
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">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 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"
|
||||
className="p-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDeleteConfirm ? (
|
||||
<div className="p-6 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
|
||||
<Trash2 className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Удалить задачу?</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
Задача "{task.title}" будет удалена безвозвратно.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="flex-1 btn bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="flex-1 btn bg-red-500 text-white hover:bg-red-600"
|
||||
>
|
||||
{deleteMutation.isPending ? "Удаляем..." : "Удалить"}
|
||||
</button>
|
||||
<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 dark:text-gray-300 mb-1.5">
|
||||
Название
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="input"
|
||||
placeholder="Что нужно сделать?"
|
||||
/>
|
||||
</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="Что нужно сделать?"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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-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="relative">
|
||||
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||||
<input
|
||||
type="time"
|
||||
value={reminderTime}
|
||||
onChange={(e) => setReminderTime(e.target.value)}
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Получишь напоминание в Telegram в указанное время
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Срок выполнения
|
||||
</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<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>
|
||||
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 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
</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 space-y-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending}
|
||||
className="btn btn-primary w-full"
|
||||
>
|
||||
{updateMutation.isPending ? "Сохраняем..." : "Сохранить изменения"}
|
||||
Сегодня
|
||||
</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 dark:bg-gray-800 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 dark:bg-gray-800 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 dark:text-gray-500" size={18} />
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recurring Section */}
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Repeat size={18} className={isRecurring ? "text-primary-500" : "text-gray-400 dark:text-gray-500"} />
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Повторять</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsRecurring(!isRecurring)}
|
||||
className={clsx(
|
||||
"w-12 h-6 rounded-full transition-all relative",
|
||||
isRecurring ? "bg-primary-500" : "bg-gray-200"
|
||||
)}
|
||||
>
|
||||
<div className={clsx(
|
||||
"absolute top-1 w-4 h-4 rounded-full bg-white dark:bg-gray-900 shadow-sm transition-all",
|
||||
isRecurring ? "right-1" : "left-1"
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isRecurring && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{RECURRENCE_TYPES.map((type) => (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
onClick={() => setRecurrenceType(type.value)}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||
recurrenceType === type.value
|
||||
? "bg-primary-500 text-white"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{recurrenceType === "custom" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Каждые</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
value={recurrenceInterval}
|
||||
onChange={(e) => setRecurrenceInterval(parseInt(e.target.value) || 1)}
|
||||
className="input w-20 text-center"
|
||||
/>
|
||||
<span className="text-sm text-gray-600">дней</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1">
|
||||
Повторять до (опционально)
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={recurrenceEndDate}
|
||||
onChange={(e) => setRecurrenceEndDate(e.target.value)}
|
||||
className="input"
|
||||
min={dueDate || today}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Напоминание (опционально)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
|
||||
<input
|
||||
type="time"
|
||||
value={reminderTime}
|
||||
onChange={(e) => setReminderTime(e.target.value)}
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
|
||||
Получишь напоминание в Telegram в указанное время
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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 dark:bg-gray-800 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 dark:text-gray-400 dark: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 dark:bg-gray-800 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{ic}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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 space-y-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending}
|
||||
className="btn btn-primary w-full"
|
||||
>
|
||||
{updateMutation.isPending ? "Сохраняем..." : "Сохранить"}
|
||||
</button>
|
||||
|
||||
{!showDeleteConfirm ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="btn w-full bg-red-50 text-red-600 hover:bg-red-100 flex items-center justify-center gap-2"
|
||||
className="btn w-full flex items-center justify-center gap-2 text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
Удалить задачу
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="btn flex-1"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="btn flex-1 bg-red-500 text-white hover:bg-red-600"
|
||||
>
|
||||
{deleteMutation.isPending ? "Удаляем..." : "Да, удалить"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
|
||||
209
src/components/LogHabitModal.jsx
Normal file
209
src/components/LogHabitModal.jsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X, ChevronLeft, ChevronRight, Check } from 'lucide-react'
|
||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isFuture, startOfDay, subMonths, addMonths, isToday } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export default function LogHabitModal({ open, onClose, habit, completedDates = [], onLogDate }) {
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date())
|
||||
const [selectedDate, setSelectedDate] = useState(null)
|
||||
const [isLogging, setIsLogging] = useState(false)
|
||||
|
||||
const days = useMemo(() => {
|
||||
const start = startOfMonth(currentMonth)
|
||||
const end = endOfMonth(currentMonth)
|
||||
return eachDayOfInterval({ start, end })
|
||||
}, [currentMonth])
|
||||
|
||||
// Convert completedDates to a Set for faster lookup
|
||||
const completedSet = useMemo(() => {
|
||||
const set = new Set()
|
||||
completedDates.forEach(d => {
|
||||
const dateStr = typeof d === 'string' ? d.split('T')[0] : format(d, 'yyyy-MM-dd')
|
||||
set.add(dateStr)
|
||||
})
|
||||
return set
|
||||
}, [completedDates])
|
||||
|
||||
const isDateCompleted = (date) => {
|
||||
return completedSet.has(format(date, 'yyyy-MM-dd'))
|
||||
}
|
||||
|
||||
const handleDateClick = (date) => {
|
||||
if (isFuture(startOfDay(date))) return
|
||||
if (isDateCompleted(date)) return
|
||||
setSelectedDate(date)
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!selectedDate) return
|
||||
setIsLogging(true)
|
||||
try {
|
||||
await onLogDate(habit.id, format(selectedDate, 'yyyy-MM-dd'))
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Failed to log habit:', error)
|
||||
} finally {
|
||||
setIsLogging(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Get first day of week offset
|
||||
const firstDayOfMonth = startOfMonth(currentMonth)
|
||||
const startOffset = (firstDayOfMonth.getDay() + 6) % 7 // Monday = 0
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="bg-white dark:bg-gray-900 rounded-3xl shadow-2xl w-full max-w-sm overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-5 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center text-xl"
|
||||
style={{ backgroundColor: habit?.color + '20' }}
|
||||
>
|
||||
{habit?.icon || '✨'}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-display font-bold text-gray-900 dark:text-white">Отметить привычку</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 dark:text-gray-500">{habit?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800 rounded-xl transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Calendar */}
|
||||
<div className="p-5">
|
||||
{/* Month navigation */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={() => setCurrentMonth(m => subMonths(m, 1))}
|
||||
className="p-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800 rounded-xl transition-colors"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<span className="font-semibold text-gray-900 dark:text-white capitalize">
|
||||
{format(currentMonth, 'LLLL yyyy', { locale: ru })}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentMonth(m => addMonths(m, 1))}
|
||||
disabled={isSameDay(startOfMonth(currentMonth), startOfMonth(new Date()))}
|
||||
className={clsx(
|
||||
"p-2 rounded-xl transition-colors",
|
||||
isSameDay(startOfMonth(currentMonth), startOfMonth(new Date()))
|
||||
? "text-gray-200 cursor-not-allowed"
|
||||
: "text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800"
|
||||
)}
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => (
|
||||
<div key={day} className="text-center text-xs font-medium text-gray-400 dark:text-gray-500 py-2">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{/* Empty cells for offset */}
|
||||
{Array.from({ length: startOffset }).map((_, i) => (
|
||||
<div key={`offset-${i}`} className="aspect-square" />
|
||||
))}
|
||||
|
||||
{/* Days */}
|
||||
{days.map(day => {
|
||||
const completed = isDateCompleted(day)
|
||||
const future = isFuture(startOfDay(day))
|
||||
const selected = selectedDate && isSameDay(day, selectedDate)
|
||||
const today = isToday(day)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day.toISOString()}
|
||||
onClick={() => handleDateClick(day)}
|
||||
disabled={future || completed}
|
||||
className={clsx(
|
||||
"aspect-square rounded-xl flex items-center justify-center text-sm font-medium transition-all",
|
||||
future && "text-gray-200 cursor-not-allowed",
|
||||
completed && "bg-green-100 text-green-600 cursor-default",
|
||||
selected && !completed && "bg-primary-500 text-white shadow-lg shadow-primary-500/30",
|
||||
!future && !completed && !selected && "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800",
|
||||
today && !selected && !completed && "ring-2 ring-primary-200"
|
||||
)}
|
||||
>
|
||||
{completed ? (
|
||||
<Check size={16} className="text-green-600" />
|
||||
) : (
|
||||
format(day, 'd')
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Selected date info */}
|
||||
{selectedDate && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-4 p-3 bg-primary-50 rounded-xl text-center"
|
||||
>
|
||||
<p className="text-sm text-primary-700">
|
||||
Выбрано: <span className="font-semibold">{format(selectedDate, 'd MMMM yyyy', { locale: ru })}</span>
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="p-5 pt-0 flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-3 px-4 rounded-xl font-semibold text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedDate || isLogging}
|
||||
className={clsx(
|
||||
"flex-1 py-3 px-4 rounded-xl font-semibold text-white transition-all",
|
||||
selectedDate && !isLogging
|
||||
? "bg-primary-500 hover:bg-primary-600 shadow-lg shadow-primary-500/30"
|
||||
: "bg-gray-300 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{isLogging ? 'Сохранение...' : 'Отметить'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NavLink } from "react-router-dom"
|
||||
import { Home, ListChecks, CheckSquare, BarChart3, Settings } from "lucide-react"
|
||||
import { Home, ListChecks, CheckSquare, BarChart3, PiggyBank, Settings } from "lucide-react"
|
||||
import clsx from "clsx"
|
||||
|
||||
export default function Navigation() {
|
||||
@@ -8,12 +8,13 @@ export default function Navigation() {
|
||||
{ to: "/habits", icon: ListChecks, label: "Привычки" },
|
||||
{ to: "/tasks", icon: CheckSquare, label: "Задачи" },
|
||||
{ to: "/stats", icon: BarChart3, label: "Статистика" },
|
||||
{ to: "/savings", icon: PiggyBank, label: "Накопления" },
|
||||
{ to: "/settings", icon: Settings, label: "Настройки" },
|
||||
]
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 bg-white/80 backdrop-blur-xl border-t border-gray-100 z-50">
|
||||
<div className="max-w-lg mx-auto px-4">
|
||||
<nav className="fixed bottom-0 left-0 right-0 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl border-t border-gray-100 dark:border-gray-800 z-50 transition-colors duration-300">
|
||||
<div className="max-w-lg mx-auto px-2">
|
||||
<div className="flex items-center justify-around py-2">
|
||||
{navItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
@@ -21,15 +22,15 @@ export default function Navigation() {
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
"flex flex-col items-center gap-1 px-2 py-2 rounded-xl transition-all",
|
||||
"flex flex-col items-center gap-0.5 px-1.5 py-1.5 rounded-xl transition-all",
|
||||
isActive
|
||||
? "text-primary-600 bg-primary-50"
|
||||
: "text-gray-400 hover:text-gray-600"
|
||||
? "text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/30"
|
||||
: "text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
<Icon size={18} />
|
||||
<span className="text-[10px] font-medium">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
42
src/contexts/ThemeContext.jsx
Normal file
42
src/contexts/ThemeContext.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
const ThemeContext = createContext()
|
||||
|
||||
export function ThemeProvider({ children }) {
|
||||
const [theme, setTheme] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem("theme") || "dark"
|
||||
}
|
||||
return "dark"
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
|
||||
if (theme === "dark") {
|
||||
root.classList.add("dark")
|
||||
} else {
|
||||
root.classList.remove("dark")
|
||||
}
|
||||
|
||||
localStorage.setItem("theme", theme)
|
||||
}, [theme])
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prev => prev === "dark" ? "light" : "dark")
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) {
|
||||
throw new Error("useTheme must be used within ThemeProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -11,7 +11,9 @@
|
||||
|
||||
body {
|
||||
@apply bg-surface-50 text-gray-900 antialiased;
|
||||
@apply dark:bg-gray-950 dark:text-gray-100;
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,12 +30,16 @@
|
||||
@apply hover:from-primary-700 hover:to-primary-800;
|
||||
@apply focus:ring-primary-500;
|
||||
@apply shadow-lg shadow-primary-500/25;
|
||||
@apply dark:from-primary-500 dark:to-primary-600;
|
||||
@apply dark:hover:from-primary-600 dark:hover:to-primary-700;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-white text-gray-700 border border-gray-200;
|
||||
@apply hover:bg-gray-50 hover:border-gray-300;
|
||||
@apply focus:ring-gray-500;
|
||||
@apply dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700;
|
||||
@apply dark:hover:bg-gray-700 dark:hover:border-gray-600;
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
@@ -47,16 +53,21 @@
|
||||
@apply w-full px-4 py-3.5 rounded-2xl border border-gray-200 bg-white;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500;
|
||||
@apply placeholder:text-gray-400 transition-all duration-200;
|
||||
@apply dark:bg-gray-900 dark:border-gray-700 dark:text-gray-100;
|
||||
@apply dark:placeholder:text-gray-500 dark:focus:border-primary-400;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-3xl shadow-sm border border-gray-100/50;
|
||||
@apply hover:shadow-md transition-shadow duration-300;
|
||||
@apply hover:shadow-md transition-all duration-300;
|
||||
@apply dark:bg-gray-900 dark:border-gray-800 dark:shadow-none;
|
||||
@apply dark:hover:bg-gray-800;
|
||||
}
|
||||
|
||||
.card-glass {
|
||||
@apply bg-white/70 backdrop-blur-xl rounded-3xl;
|
||||
@apply border border-white/20 shadow-lg;
|
||||
@apply dark:bg-gray-900/70 dark:border-gray-700/50;
|
||||
}
|
||||
|
||||
.gradient-mesh {
|
||||
@@ -65,6 +76,13 @@
|
||||
radial-gradient(at 80% 0%, rgba(247, 181, 56, 0.08) 0px, transparent 50%),
|
||||
radial-gradient(at 0% 50%, rgba(20, 184, 166, 0.05) 0px, transparent 50%);
|
||||
}
|
||||
|
||||
.dark .gradient-mesh {
|
||||
background:
|
||||
radial-gradient(at 40% 20%, rgba(20, 184, 166, 0.15) 0px, transparent 50%),
|
||||
radial-gradient(at 80% 0%, rgba(247, 181, 56, 0.1) 0px, transparent 50%),
|
||||
radial-gradient(at 0% 50%, rgba(20, 184, 166, 0.08) 0px, transparent 50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
@@ -78,8 +96,10 @@
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 rounded-full;
|
||||
@apply dark:bg-gray-700;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-400;
|
||||
@apply dark:bg-gray-600;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
@@ -17,9 +18,11 @@ const queryClient = new QueryClient({
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -28,11 +28,8 @@ export default function Habits() {
|
||||
enabled: showArchived,
|
||||
})
|
||||
|
||||
// Загружаем статистику для каждой привычки
|
||||
useEffect(() => {
|
||||
if (habits.length > 0) {
|
||||
loadStats()
|
||||
}
|
||||
if (habits.length > 0) loadStats()
|
||||
}, [habits])
|
||||
|
||||
const loadStats = async () => {
|
||||
@@ -41,9 +38,7 @@ export default function Habits() {
|
||||
try {
|
||||
const stats = await habitsApi.getHabitStats(habit.id)
|
||||
statsMap[habit.id] = stats
|
||||
} catch (e) {
|
||||
console.error('Error loading stats for habit', habit.id, e)
|
||||
}
|
||||
} catch (e) {}
|
||||
}))
|
||||
setHabitStats(statsMap)
|
||||
}
|
||||
@@ -63,9 +58,8 @@ export default function Habits() {
|
||||
const days = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
|
||||
return habit.target_days.map(d => days[d - 1]).join(', ')
|
||||
}
|
||||
if (habit.frequency === 'custom') {
|
||||
return `Каждые ${habit.target_count} дн.`
|
||||
}
|
||||
if (habit.frequency === 'interval') return `Каждые ${habit.target_count} дн.`
|
||||
if (habit.frequency === 'custom') return `Каждые ${habit.target_count} дн.`
|
||||
return habit.frequency
|
||||
}
|
||||
|
||||
@@ -73,17 +67,14 @@ export default function Habits() {
|
||||
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="min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300">
|
||||
<header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 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>
|
||||
<h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">Мои привычки</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{activeHabits.length} активных</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn btn-primary flex items-center gap-2"
|
||||
>
|
||||
<button onClick={() => setShowCreateModal(true)} className="btn btn-primary flex items-center gap-2">
|
||||
<Plus size={18} />
|
||||
Новая
|
||||
</button>
|
||||
@@ -96,37 +87,29 @@ export default function Habits() {
|
||||
{[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="w-14 h-14 rounded-2xl bg-gray-200 dark:bg-gray-700" />
|
||||
<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 className="h-5 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/2 mb-2" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 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" />
|
||||
<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 dark:from-primary-900/30 dark:to-accent-900/30 flex items-center justify-center mx-auto mb-5">
|
||||
<Plus className="w-10 h-10 text-primary-600 dark:text-primary-400" />
|
||||
</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"
|
||||
>
|
||||
<h3 className="text-xl font-display font-bold text-gray-900 dark:text-white mb-2">Нет привычек</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 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) => (
|
||||
@@ -143,32 +126,17 @@ export default function Habits() {
|
||||
</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"
|
||||
>
|
||||
<button onClick={() => setShowArchived(!showArchived)} className="flex items-center gap-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 mb-4">
|
||||
<Archive size={18} />
|
||||
<span className="font-medium">Архив ({archivedList.length})</span>
|
||||
<ChevronRight
|
||||
size={18}
|
||||
className={clsx(
|
||||
'transition-transform',
|
||||
showArchived && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<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}
|
||||
@@ -178,21 +146,14 @@ export default function Habits() {
|
||||
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' }}
|
||||
>
|
||||
<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>
|
||||
<h3 className="font-semibold text-gray-600 dark:text-gray-400 truncate">{habit.name}</h3>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">{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="Восстановить"
|
||||
>
|
||||
<button onClick={() => archiveMutation.mutate({ id: habit.id, archived: false })} className="p-2 text-gray-400 hover:text-green-500 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-xl transition-all" title="Восстановить">
|
||||
<ArchiveRestore size={20} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -208,17 +169,8 @@ export default function Habits() {
|
||||
</main>
|
||||
|
||||
<Navigation />
|
||||
|
||||
<CreateHabitModal
|
||||
open={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
/>
|
||||
|
||||
<EditHabitModal
|
||||
open={!!editingHabit}
|
||||
onClose={() => setEditingHabit(null)}
|
||||
habit={editingHabit}
|
||||
/>
|
||||
<CreateHabitModal open={showCreateModal} onClose={() => setShowCreateModal(false)} />
|
||||
<EditHabitModal open={!!editingHabit} onClose={() => setEditingHabit(null)} habit={editingHabit} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -234,20 +186,14 @@ function HabitListItem({ habit, index, stats, frequencyLabel, onEdit, onArchive
|
||||
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' }}
|
||||
>
|
||||
<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>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white 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 }}
|
||||
>
|
||||
<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 && (
|
||||
@@ -262,11 +208,11 @@ function HabitListItem({ habit, index, stats, frequencyLabel, onEdit, onArchive
|
||||
<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>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">{stats.this_month}</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">в месяц</p>
|
||||
</div>
|
||||
)}
|
||||
<ChevronRight size={20} className="text-gray-300" />
|
||||
<ChevronRight size={20} className="text-gray-300 dark:text-gray-600" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,51 +1,62 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Check, Flame, TrendingUp, Zap, Sparkles, Undo2, Plus, Calendar, AlertTriangle, LogOut } from 'lucide-react'
|
||||
import { format, startOfWeek, differenceInDays, parseISO, isToday, isTomorrow, isPast } from 'date-fns'
|
||||
import { Check, Flame, TrendingUp, Zap, Sparkles, Undo2, Plus, Calendar, AlertTriangle, LogOut, Snowflake } from 'lucide-react'
|
||||
import { format, startOfWeek, differenceInDays, parseISO, isToday, isTomorrow, isPast, startOfDay, isBefore, isAfter } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
import { habitsApi } from '../api/habits'
|
||||
import { tasksApi } from '../api/tasks'
|
||||
import { useAuthStore } from '../store/auth'
|
||||
import Navigation from '../components/Navigation'
|
||||
import CreateTaskModal from '../components/CreateTaskModal'
|
||||
import LogHabitModal from '../components/LogHabitModal'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// Check if habit is frozen on a specific date
|
||||
function isHabitFrozenOnDate(habit, freezes, date) {
|
||||
if (!freezes || freezes.length === 0) return false
|
||||
const checkDate = startOfDay(date)
|
||||
return freezes.some(freeze => {
|
||||
const start = startOfDay(parseISO(freeze.start_date))
|
||||
const end = startOfDay(parseISO(freeze.end_date))
|
||||
return !isBefore(checkDate, start) && !isAfter(checkDate, end)
|
||||
})
|
||||
}
|
||||
|
||||
// Определение "сегодняшних" привычек
|
||||
function shouldShowToday(habit, lastLogDate) {
|
||||
const today = new Date()
|
||||
const dayOfWeek = today.getDay() || 7 // 1=Пн, 7=Вс (JS: 0=Вс -> 7)
|
||||
function shouldShowToday(habit, lastLogDate, freezes) {
|
||||
const today = startOfDay(new Date())
|
||||
const dayOfWeek = today.getDay() || 7
|
||||
|
||||
if (habit.frequency === 'daily') {
|
||||
return true
|
||||
}
|
||||
if (isHabitFrozenOnDate(habit, freezes, today)) return false
|
||||
|
||||
if (habit.frequency === 'weekly') {
|
||||
// Проверяем, выбран ли сегодняшний день
|
||||
if (habit.target_days && habit.target_days.includes(dayOfWeek)) {
|
||||
return true
|
||||
const startDate = habit.start_date
|
||||
? startOfDay(parseISO(habit.start_date))
|
||||
: startOfDay(parseISO(habit.created_at))
|
||||
|
||||
if (today < startDate) return false
|
||||
|
||||
if (habit.frequency === "daily") return true
|
||||
|
||||
if (habit.frequency === "weekly") {
|
||||
if (habit.target_days && habit.target_days.length > 0) {
|
||||
return habit.target_days.includes(dayOfWeek)
|
||||
}
|
||||
|
||||
// Если не выполнялась на этой неделе
|
||||
if (!lastLogDate) return true
|
||||
|
||||
const weekStart = startOfWeek(today, { weekStartsOn: 1 })
|
||||
const lastLog = typeof lastLogDate === 'string' ? parseISO(lastLogDate) : lastLogDate
|
||||
|
||||
if (lastLog < weekStart) {
|
||||
return true // Не выполнялась на этой неделе
|
||||
}
|
||||
|
||||
return false
|
||||
const lastLog = typeof lastLogDate === "string" ? parseISO(lastLogDate) : lastLogDate
|
||||
return lastLog < weekStart
|
||||
}
|
||||
|
||||
// custom (every_n_days) - показывать если прошло N+ дней
|
||||
if (habit.frequency === 'custom' && habit.target_count > 0) {
|
||||
if (!lastLogDate) return true
|
||||
|
||||
const lastLog = typeof lastLogDate === 'string' ? parseISO(lastLogDate) : lastLogDate
|
||||
const daysSinceLastLog = differenceInDays(today, lastLog)
|
||||
|
||||
if (habit.frequency === "interval" && habit.target_count > 0) {
|
||||
const daysSinceStart = differenceInDays(today, startDate)
|
||||
return daysSinceStart % habit.target_count === 0
|
||||
}
|
||||
|
||||
if (habit.frequency === "custom" && habit.target_count > 0) {
|
||||
if (!lastLogDate) return today >= startDate
|
||||
const lastLog = typeof lastLogDate === "string" ? parseISO(lastLogDate) : lastLogDate
|
||||
const daysSinceLastLog = differenceInDays(today, startOfDay(lastLog))
|
||||
return daysSinceLastLog >= habit.target_count
|
||||
}
|
||||
|
||||
@@ -63,7 +74,10 @@ function formatDueDate(dateStr) {
|
||||
export default function Home() {
|
||||
const [todayLogs, setTodayLogs] = useState({})
|
||||
const [lastLogDates, setLastLogDates] = useState({})
|
||||
const [habitFreezes, setHabitFreezes] = useState({})
|
||||
const [habitLogs, setHabitLogs] = useState({})
|
||||
const [showCreateTask, setShowCreateTask] = useState(false)
|
||||
const [logHabitModal, setLogHabitModal] = useState({ open: false, habit: null })
|
||||
const queryClient = useQueryClient()
|
||||
const { user, logout } = useAuthStore()
|
||||
|
||||
@@ -85,6 +99,7 @@ export default function Home() {
|
||||
useEffect(() => {
|
||||
if (habits.length > 0) {
|
||||
loadTodayLogs()
|
||||
loadHabitFreezes()
|
||||
}
|
||||
}, [habits])
|
||||
|
||||
@@ -92,20 +107,18 @@ export default function Home() {
|
||||
const today = format(new Date(), 'yyyy-MM-dd')
|
||||
const logsMap = {}
|
||||
const lastDates = {}
|
||||
const allLogs = {}
|
||||
|
||||
await Promise.all(habits.map(async (habit) => {
|
||||
try {
|
||||
const logs = await habitsApi.getLogs(habit.id, 30)
|
||||
const logs = await habitsApi.getLogs(habit.id, 90)
|
||||
allLogs[habit.id] = logs.map(l => l.date)
|
||||
|
||||
// Находим последний лог
|
||||
if (logs.length > 0) {
|
||||
const lastLog = logs[0]
|
||||
const logDate = lastLog.date.split('T')[0]
|
||||
lastDates[habit.id] = logDate
|
||||
|
||||
if (logDate === today) {
|
||||
logsMap[habit.id] = lastLog.id
|
||||
}
|
||||
if (logDate === today) logsMap[habit.id] = lastLog.id
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading logs for habit', habit.id, e)
|
||||
@@ -114,14 +127,31 @@ export default function Home() {
|
||||
|
||||
setTodayLogs(logsMap)
|
||||
setLastLogDates(lastDates)
|
||||
setHabitLogs(allLogs)
|
||||
}
|
||||
|
||||
const loadHabitFreezes = async () => {
|
||||
const freezesMap = {}
|
||||
await Promise.all(habits.map(async (habit) => {
|
||||
try {
|
||||
const freezes = await habitsApi.getFreezes(habit.id)
|
||||
freezesMap[habit.id] = freezes
|
||||
} catch (e) {
|
||||
freezesMap[habit.id] = []
|
||||
}
|
||||
}))
|
||||
setHabitFreezes(freezesMap)
|
||||
}
|
||||
|
||||
const logMutation = useMutation({
|
||||
mutationFn: (habitId) => habitsApi.log(habitId),
|
||||
onSuccess: (data, habitId) => {
|
||||
mutationFn: ({ habitId, date }) => habitsApi.log(habitId, date ? { date } : {}),
|
||||
onSuccess: (data, { habitId, date }) => {
|
||||
const logDate = date || format(new Date(), 'yyyy-MM-dd')
|
||||
const today = format(new Date(), 'yyyy-MM-dd')
|
||||
setTodayLogs(prev => ({ ...prev, [habitId]: data.id }))
|
||||
setLastLogDates(prev => ({ ...prev, [habitId]: today }))
|
||||
|
||||
if (logDate === today) setTodayLogs(prev => ({ ...prev, [habitId]: data.id }))
|
||||
setLastLogDates(prev => ({ ...prev, [habitId]: logDate }))
|
||||
setHabitLogs(prev => ({ ...prev, [habitId]: [...(prev[habitId] || []), logDate] }))
|
||||
queryClient.invalidateQueries({ queryKey: ['habits'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['stats'] })
|
||||
},
|
||||
@@ -135,7 +165,6 @@ export default function Home() {
|
||||
delete newLogs[habitId]
|
||||
return newLogs
|
||||
})
|
||||
// Перезагружаем логи для обновления lastLogDate
|
||||
loadTodayLogs()
|
||||
queryClient.invalidateQueries({ queryKey: ['habits'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['stats'] })
|
||||
@@ -162,50 +191,51 @@ export default function Home() {
|
||||
if (todayLogs[habitId]) {
|
||||
deleteLogMutation.mutate({ habitId, logId: todayLogs[habitId] })
|
||||
} else {
|
||||
logMutation.mutate(habitId)
|
||||
logMutation.mutate({ habitId })
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogHabitDate = async (habitId, date) => {
|
||||
await logMutation.mutateAsync({ habitId, date })
|
||||
}
|
||||
|
||||
const handleToggleTask = (task) => {
|
||||
if (task.completed) {
|
||||
uncompleteTaskMutation.mutate(task.id)
|
||||
} else {
|
||||
completeTaskMutation.mutate(task.id)
|
||||
}
|
||||
if (task.completed) uncompleteTaskMutation.mutate(task.id)
|
||||
else completeTaskMutation.mutate(task.id)
|
||||
}
|
||||
|
||||
// Фильтруем привычки для сегодня
|
||||
const todayHabits = useMemo(() => {
|
||||
return habits.filter(habit =>
|
||||
shouldShowToday(habit, lastLogDates[habit.id])
|
||||
)
|
||||
}, [habits, lastLogDates])
|
||||
return habits.filter(habit => shouldShowToday(habit, lastLogDates[habit.id], habitFreezes[habit.id]))
|
||||
}, [habits, lastLogDates, habitFreezes])
|
||||
|
||||
const frozenHabits = useMemo(() => {
|
||||
const today = startOfDay(new Date())
|
||||
return habits.filter(habit => isHabitFrozenOnDate(habit, habitFreezes[habit.id], today))
|
||||
}, [habits, habitFreezes])
|
||||
|
||||
const completedCount = Object.keys(todayLogs).length
|
||||
const totalToday = todayHabits.length
|
||||
const today = format(new Date(), 'EEEE, d MMMM', { locale: ru })
|
||||
|
||||
// Активные задачи (не выполненные)
|
||||
const activeTasks = todayTasks.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="min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300">
|
||||
<header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 sticky top-0 z-10">
|
||||
<div className="max-w-lg mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center shadow-lg shadow-primary-500/20">
|
||||
<Zap className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-display font-bold text-gray-900">
|
||||
<h1 className="text-lg font-display font-bold text-gray-900 dark:text-white">
|
||||
Привет, {user?.username}!
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 capitalize">{today}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 capitalize">{today}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-xl transition-colors"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors"
|
||||
title="Выйти"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
@@ -214,19 +244,13 @@ export default function Home() {
|
||||
</header>
|
||||
|
||||
<main className="max-w-lg mx-auto px-4 py-6 space-y-6">
|
||||
{/* Прогресс на сегодня */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="card p-5"
|
||||
>
|
||||
{/* Progress */}
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="card p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-semibold text-gray-900">Прогресс на сегодня</h2>
|
||||
<span className="text-sm font-medium text-primary-600">
|
||||
{completedCount} / {totalToday}
|
||||
</span>
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white">Прогресс на сегодня</h2>
|
||||
<span className="text-sm font-medium text-primary-600 dark:text-primary-400">{completedCount} / {totalToday}</span>
|
||||
</div>
|
||||
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-3 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: totalToday > 0 ? `${(completedCount / totalToday) * 100}%` : '0%' }}
|
||||
@@ -235,62 +259,55 @@ export default function Home() {
|
||||
/>
|
||||
</div>
|
||||
{completedCount === totalToday && totalToday > 0 && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-sm text-green-600 mt-2 font-medium"
|
||||
>
|
||||
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-sm text-green-600 dark:text-green-400 mt-2 font-medium">
|
||||
🎉 Все привычки выполнены!
|
||||
</motion.p>
|
||||
)}
|
||||
{frozenHabits.length > 0 && (
|
||||
<div className="flex items-center gap-2 mt-2 text-sm text-cyan-600 dark:text-cyan-400">
|
||||
<Snowflake size={14} />
|
||||
<span>{frozenHabits.length} привычек на паузе</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Статистика */}
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="card p-5"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="card p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-accent-400 to-accent-500 flex items-center justify-center shadow-lg shadow-accent-400/20">
|
||||
<Flame className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-display font-bold text-gray-900">{stats.today_completed}</p>
|
||||
<p className="text-sm text-gray-500">Выполнено</p>
|
||||
<p className="text-3xl font-display font-bold text-gray-900 dark:text-white">{stats.today_completed}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Выполнено</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="card p-5"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }} className="card p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center shadow-lg shadow-primary-500/20">
|
||||
<TrendingUp className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-display font-bold text-gray-900">{stats.active_habits}</p>
|
||||
<p className="text-sm text-gray-500">Активных</p>
|
||||
<p className="text-3xl font-display font-bold text-gray-900 dark:text-white">{stats.active_habits}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Активных</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Задачи на сегодня */}
|
||||
{/* Tasks */}
|
||||
{(activeTasks.length > 0 || !tasksLoading) && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-display font-bold text-gray-900">Задачи на сегодня</h2>
|
||||
<h2 className="text-xl font-display font-bold text-gray-900 dark:text-white">Задачи на сегодня</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateTask(true)}
|
||||
className="p-2 bg-primary-100 text-primary-600 rounded-xl hover:bg-primary-200 transition-colors"
|
||||
className="p-2 bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded-xl hover:bg-primary-200 dark:hover:bg-primary-800/40 transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
@@ -299,24 +316,17 @@ export default function Home() {
|
||||
{tasksLoading ? (
|
||||
<div 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="w-10 h-10 rounded-xl bg-gray-200 dark:bg-gray-700" />
|
||||
<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 className="h-5 bg-gray-200 dark:bg-gray-700 rounded-lg w-3/4 mb-2" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : activeTasks.length === 0 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="card p-6 text-center"
|
||||
>
|
||||
<p className="text-gray-500">Нет задач на сегодня</p>
|
||||
<button
|
||||
onClick={() => setShowCreateTask(true)}
|
||||
className="mt-3 text-sm text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="card p-6 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">Нет задач на сегодня</p>
|
||||
<button onClick={() => setShowCreateTask(true)} className="mt-3 text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 font-medium">
|
||||
+ Добавить задачу
|
||||
</button>
|
||||
</motion.div>
|
||||
@@ -324,13 +334,7 @@ export default function Home() {
|
||||
<div className="space-y-3">
|
||||
<AnimatePresence>
|
||||
{activeTasks.map((task, index) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
index={index}
|
||||
onToggle={() => handleToggleTask(task)}
|
||||
isLoading={completeTaskMutation.isPending || uncompleteTaskMutation.isPending}
|
||||
/>
|
||||
<TaskCard key={task.id} task={task} index={index} onToggle={() => handleToggleTask(task)} isLoading={completeTaskMutation.isPending || uncompleteTaskMutation.isPending} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
@@ -338,35 +342,31 @@ export default function Home() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Привычки на сегодня */}
|
||||
{/* Habits */}
|
||||
<div>
|
||||
<h2 className="text-xl font-display font-bold text-gray-900 mb-5">Привычки</h2>
|
||||
<h2 className="text-xl font-display font-bold text-gray-900 dark:text-white mb-5">Привычки</h2>
|
||||
|
||||
{habitsLoading ? (
|
||||
<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="w-14 h-14 rounded-2xl bg-gray-200 dark:bg-gray-700" />
|
||||
<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 className="h-5 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/2 mb-2" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : todayHabits.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-green-100 to-green-200 flex items-center justify-center mx-auto mb-5">
|
||||
<Sparkles className="w-10 h-10 text-green-600" />
|
||||
<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-green-100 to-green-200 dark:from-green-900/30 dark:to-green-800/30 flex items-center justify-center mx-auto mb-5">
|
||||
<Sparkles className="w-10 h-10 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h3 className="text-xl font-display font-bold text-gray-900 mb-2">Свободный день!</h3>
|
||||
<p className="text-gray-500">На сегодня нет запланированных привычек. Отдохни или добавь новую во вкладке "Привычки".</p>
|
||||
<h3 className="text-xl font-display font-bold text-gray-900 dark:text-white mb-2">Свободный день!</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">На сегодня нет запланированных привычек.</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
@@ -378,6 +378,7 @@ export default function Home() {
|
||||
index={index}
|
||||
isCompleted={!!todayLogs[habit.id]}
|
||||
onToggle={() => handleToggleComplete(habit.id)}
|
||||
onLongPress={() => setLogHabitModal({ open: true, habit })}
|
||||
isLoading={logMutation.isPending || deleteLogMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
@@ -388,10 +389,13 @@ export default function Home() {
|
||||
</main>
|
||||
|
||||
<Navigation />
|
||||
|
||||
<CreateTaskModal
|
||||
open={showCreateTask}
|
||||
onClose={() => setShowCreateTask(false)}
|
||||
<CreateTaskModal open={showCreateTask} onClose={() => setShowCreateTask(false)} />
|
||||
<LogHabitModal
|
||||
open={logHabitModal.open}
|
||||
onClose={() => setLogHabitModal({ open: false, habit: null })}
|
||||
habit={logHabitModal.habit}
|
||||
completedDates={habitLogs[logHabitModal.habit?.id] || []}
|
||||
onLogDate={handleLogHabitDate}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -421,21 +425,12 @@ function TaskCard({ task, index, onToggle, isLoading }) {
|
||||
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"
|
||||
>
|
||||
<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]
|
||||
}}
|
||||
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 }}
|
||||
@@ -455,17 +450,10 @@ function TaskCard({ task, index, onToggle, isLoading }) {
|
||||
? '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'
|
||||
}}
|
||||
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 }}
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
@@ -474,16 +462,9 @@ function TaskCard({ task, index, onToggle, isLoading }) {
|
||||
</motion.button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className={clsx(
|
||||
"font-semibold truncate",
|
||||
task.completed ? "text-gray-400 line-through" : "text-gray-900"
|
||||
)}>{task.title}</h3>
|
||||
|
||||
<h3 className={clsx("font-semibold truncate", task.completed ? "text-gray-400 line-through" : "text-gray-900 dark:text-white")}>{task.title}</h3>
|
||||
{(dueDateLabel || isOverdue) && (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-1 text-xs font-medium mt-1',
|
||||
isOverdue ? 'text-red-600' : 'text-gray-500'
|
||||
)}>
|
||||
<span className={clsx('inline-flex items-center gap-1 text-xs font-medium mt-1', isOverdue ? 'text-red-600' : 'text-gray-500 dark:text-gray-400')}>
|
||||
{isOverdue && <AlertTriangle size={12} />}
|
||||
<Calendar size={12} />
|
||||
{dueDateLabel}
|
||||
@@ -492,14 +473,7 @@ function TaskCard({ task, index, onToggle, isLoading }) {
|
||||
</div>
|
||||
|
||||
{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="Отменить"
|
||||
>
|
||||
<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 dark:hover:bg-orange-900/20 rounded-xl transition-all" title="Отменить">
|
||||
<Undo2 size={18} />
|
||||
</motion.button>
|
||||
)}
|
||||
@@ -508,19 +482,27 @@ function TaskCard({ task, index, onToggle, isLoading }) {
|
||||
)
|
||||
}
|
||||
|
||||
function HabitCard({ habit, index, isCompleted, onToggle, isLoading }) {
|
||||
function HabitCard({ habit, index, isCompleted, onToggle, onLongPress, isLoading }) {
|
||||
const [showConfetti, setShowConfetti] = useState(false)
|
||||
const longPressTimer = useRef(null)
|
||||
const isLongPress = useRef(false)
|
||||
|
||||
const handleTouchStart = () => {
|
||||
isLongPress.current = false
|
||||
longPressTimer.current = setTimeout(() => { isLongPress.current = true; onLongPress() }, 500)
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => { if (longPressTimer.current) clearTimeout(longPressTimer.current) }
|
||||
|
||||
const handleCheck = (e) => {
|
||||
e.stopPropagation()
|
||||
if (isLoading) return
|
||||
if (!isCompleted) {
|
||||
setShowConfetti(true)
|
||||
setTimeout(() => setShowConfetti(false), 1000)
|
||||
}
|
||||
if (isLoading || isLongPress.current) return
|
||||
if (!isCompleted) { setShowConfetti(true); setTimeout(() => setShowConfetti(false), 1000) }
|
||||
onToggle()
|
||||
}
|
||||
|
||||
const handleContextMenu = (e) => { e.preventDefault(); onLongPress() }
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@@ -528,23 +510,15 @@ function HabitCard({ habit, index, isCompleted, onToggle, isLoading }) {
|
||||
exit={{ opacity: 0, x: -100 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="card p-5 relative overflow-hidden"
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{showConfetti && (
|
||||
<motion.div
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: 0 }}
|
||||
transition={{ duration: 1 }}
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
>
|
||||
<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]
|
||||
}}
|
||||
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: habit.color }}
|
||||
@@ -556,6 +530,11 @@ function HabitCard({ habit, index, isCompleted, onToggle, isLoading }) {
|
||||
<div className="flex items-center gap-4">
|
||||
<motion.button
|
||||
onClick={handleCheck}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onMouseDown={handleTouchStart}
|
||||
onMouseUp={handleTouchEnd}
|
||||
onMouseLeave={handleTouchEnd}
|
||||
disabled={isLoading}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={clsx(
|
||||
@@ -564,17 +543,10 @@ function HabitCard({ habit, index, isCompleted, onToggle, isLoading }) {
|
||||
? 'bg-gradient-to-br from-green-400 to-green-500 shadow-lg shadow-green-400/30'
|
||||
: 'border-2 hover:shadow-md'
|
||||
)}
|
||||
style={{
|
||||
borderColor: isCompleted ? undefined : habit.color + '40',
|
||||
backgroundColor: isCompleted ? undefined : habit.color + '10'
|
||||
}}
|
||||
style={{ borderColor: isCompleted ? undefined : habit.color + '40', backgroundColor: isCompleted ? undefined : habit.color + '10' }}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 500 }}
|
||||
>
|
||||
<motion.div initial={{ scale: 0, rotate: -180 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', stiffness: 500 }}>
|
||||
<Check className="w-7 h-7 text-white" strokeWidth={3} />
|
||||
</motion.div>
|
||||
) : (
|
||||
@@ -583,27 +555,20 @@ function HabitCard({ habit, index, isCompleted, onToggle, isLoading }) {
|
||||
</motion.button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className={clsx(
|
||||
"font-semibold text-lg truncate",
|
||||
isCompleted ? "text-gray-400 line-through" : "text-gray-900"
|
||||
)}>{habit.name}</h3>
|
||||
{habit.description && (
|
||||
<p className="text-sm text-gray-500 truncate">{habit.description}</p>
|
||||
)}
|
||||
<h3 className={clsx("font-semibold text-lg truncate", isCompleted ? "text-gray-400 line-through" : "text-gray-900 dark:text-white")}>{habit.name}</h3>
|
||||
{habit.description && <p className="text-sm text-gray-500 dark:text-gray-400 truncate">{habit.description}</p>}
|
||||
</div>
|
||||
|
||||
{isCompleted && (
|
||||
<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={20} />
|
||||
</motion.button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={(e) => { e.stopPropagation(); onLongPress() }} className="p-2 text-gray-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-xl transition-all" title="Отметить за другой день">
|
||||
<Calendar size={20} />
|
||||
</button>
|
||||
{isCompleted && (
|
||||
<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 dark:hover:bg-orange-900/20 rounded-xl transition-all" title="Отменить">
|
||||
<Undo2 size={20} />
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
@@ -30,108 +30,48 @@ export default function Login() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 gradient-mesh bg-surface-50">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
<div className="min-h-screen flex items-center justify-center p-4 gradient-mesh bg-surface-50 dark:bg-gray-950 transition-colors duration-300">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: 'spring', delay: 0.1, stiffness: 200 }}
|
||||
className="inline-flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-primary-500 to-primary-700 mb-6 shadow-xl shadow-primary-500/30"
|
||||
>
|
||||
<motion.div initial={{ scale: 0, rotate: -180 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', delay: 0.1, stiffness: 200 }} className="inline-flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-primary-500 to-primary-700 mb-6 shadow-xl shadow-primary-500/30">
|
||||
<Zap className="w-10 h-10 text-white" />
|
||||
</motion.div>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-3xl font-display font-bold text-gray-900"
|
||||
>
|
||||
<motion.h1 initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.2 }} className="text-3xl font-display font-bold text-gray-900 dark:text-white">
|
||||
С возвращением!
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="text-gray-500 mt-2"
|
||||
>
|
||||
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.3 }} className="text-gray-500 dark:text-gray-400 mt-2">
|
||||
Войди, чтобы продолжить
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="card p-8"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} className="card p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
className="p-4 rounded-2xl bg-red-50 text-red-600 text-sm font-medium"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} className="p-4 rounded-2xl bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm font-medium">
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
/>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Email</label>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input" placeholder="your@email.com" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Пароль
|
||||
</label>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Пароль</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input pr-12"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<input type={showPassword ? 'text' : 'password'} value={password} onChange={(e) => setPassword(e.target.value)} className="input pr-12" placeholder="••••••••" required />
|
||||
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-sm text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
Забыли пароль?
|
||||
</Link>
|
||||
<Link to="/forgot-password" className="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 font-medium">Забыли пароль?</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary w-full text-lg"
|
||||
>
|
||||
<button type="submit" disabled={loading} className="btn btn-primary w-full text-lg">
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||
@@ -144,12 +84,9 @@ export default function Login() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-gray-100 text-center">
|
||||
<p className="text-gray-500">
|
||||
Нет аккаунта?{' '}
|
||||
<Link to="/register" className="text-primary-600 hover:text-primary-700 font-semibold">
|
||||
Зарегистрируйся
|
||||
</Link>
|
||||
<div className="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Нет аккаунта?{' '}<Link to="/register" className="text-primary-600 dark:text-primary-400 hover:text-primary-700 font-semibold">Зарегистрируйся</Link>
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -31,103 +31,51 @@ export default function Register() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-primary-50 via-white to-accent-50">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-primary-50 via-white to-accent-50 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 transition-colors duration-300">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', delay: 0.1 }}
|
||||
className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-primary-500 to-accent-500 mb-4"
|
||||
>
|
||||
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', delay: 0.1 }} className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-primary-500 to-accent-500 mb-4">
|
||||
<Sparkles className="w-8 h-8 text-white" />
|
||||
</motion.div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Создай аккаунт</h1>
|
||||
<p className="text-gray-500 mt-1">Начни отслеживать свои привычки</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Создай аккаунт</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">Начни отслеживать свои привычки</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
className="p-3 rounded-xl bg-red-50 text-red-600 text-sm"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} className="p-3 rounded-xl bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm">
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Как тебя зовут?
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="input"
|
||||
placeholder="Имя"
|
||||
required
|
||||
/>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Как тебя зовут?</label>
|
||||
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} className="input" placeholder="Имя" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
/>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Email</label>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input" placeholder="your@email.com" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Пароль
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Пароль</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input pr-12"
|
||||
placeholder="Минимум 8 символов"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<input type={showPassword ? 'text' : 'password'} value={password} onChange={(e) => setPassword(e.target.value)} className="input pr-12" placeholder="Минимум 8 символов" minLength={8} required />
|
||||
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary w-full"
|
||||
>
|
||||
<button type="submit" disabled={loading} className="btn btn-primary w-full">
|
||||
{loading ? 'Создаём...' : 'Создать аккаунт'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-6">
|
||||
Уже есть аккаунт?{' '}
|
||||
<Link to="/login" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||
Войти
|
||||
</Link>
|
||||
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-6">
|
||||
Уже есть аккаунт?{' '}<Link to="/login" className="text-primary-600 dark:text-primary-400 hover:text-primary-700 font-medium">Войти</Link>
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
1396
src/pages/Savings.jsx
Normal file
1396
src/pages/Savings.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { ArrowLeft, Bell, MessageCircle, Globe, Save, Copy, Check, User, Sun, Moon } from "lucide-react"
|
||||
import { ArrowLeft, Bell, MessageCircle, Globe, Save, Copy, Check, User, Sun, Moon, Palette } from "lucide-react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { profileApi } from "../api/profile"
|
||||
import { useTheme } from "../contexts/ThemeContext"
|
||||
import Navigation from "../components/Navigation"
|
||||
|
||||
const TIMEZONES = [
|
||||
@@ -26,6 +27,7 @@ const TIMEZONES = [
|
||||
|
||||
export default function Settings() {
|
||||
const queryClient = useQueryClient()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [username, setUsername] = useState("")
|
||||
const [chatId, setChatId] = useState("")
|
||||
@@ -99,39 +101,80 @@ export default function Settings() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-50 flex items-center justify-center">
|
||||
<div className="min-h-screen bg-surface-50 dark:bg-gray-950 flex items-center justify-center">
|
||||
<div className="w-10 h-10 border-4 border-primary-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-50 pb-24">
|
||||
<div className="min-h-screen bg-surface-50 dark:bg-gray-950 pb-24 transition-colors duration-300">
|
||||
{/* Header */}
|
||||
<header className="bg-white/80 backdrop-blur-xl sticky top-0 z-40 border-b border-gray-100">
|
||||
<header className="bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl sticky top-0 z-40 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="max-w-lg mx-auto px-4 py-4 flex items-center gap-3">
|
||||
<Link to="/" className="p-2 -ml-2 text-gray-600 hover:text-gray-900 rounded-xl hover:bg-gray-100">
|
||||
<Link to="/" className="p-2 -ml-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
<ArrowLeft size={20} />
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">Настройки</h1>
|
||||
<h1 className="text-xl font-bold dark:text-white">Настройки</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-lg mx-auto px-4 py-6 space-y-6">
|
||||
{/* Profile Section */}
|
||||
<section className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
|
||||
{/* Theme Section */}
|
||||
<section className="bg-white dark:bg-gray-900 rounded-2xl p-4 shadow-sm dark:shadow-none border border-transparent dark:border-gray-800">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center">
|
||||
<User className="text-green-600" size={20} />
|
||||
<div className="w-10 h-10 rounded-xl bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center">
|
||||
<Palette className="text-violet-600 dark:text-violet-400" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold">Профиль</h2>
|
||||
<p className="text-sm text-gray-500">Основная информация</p>
|
||||
<h2 className="font-semibold dark:text-white">Оформление</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Выбери тему приложения</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="w-full flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-xl transition-all hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{theme === "dark" ? (
|
||||
<Moon className="text-primary-500" size={22} />
|
||||
) : (
|
||||
<Sun className="text-amber-500" size={22} />
|
||||
)}
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{theme === "dark" ? "Тёмная тема" : "Светлая тема"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className={`w-14 h-8 rounded-full transition-colors duration-300 ${theme === "dark" ? "bg-primary-500" : "bg-gray-300"}`}>
|
||||
<div className={`absolute top-1 w-6 h-6 bg-white rounded-full shadow-md transition-transform duration-300 flex items-center justify-center ${theme === "dark" ? "translate-x-7" : "translate-x-1"}`}>
|
||||
{theme === "dark" ? (
|
||||
<Moon className="text-primary-600" size={14} />
|
||||
) : (
|
||||
<Sun className="text-amber-500" size={14} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* Profile Section */}
|
||||
<section className="bg-white dark:bg-gray-900 rounded-2xl p-4 shadow-sm dark:shadow-none border border-transparent dark:border-gray-800">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<User className="text-green-600 dark:text-green-400" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold dark:text-white">Профиль</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Основная информация</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Имя пользователя
|
||||
</label>
|
||||
<input
|
||||
@@ -145,36 +188,36 @@ export default function Settings() {
|
||||
</section>
|
||||
|
||||
{/* Telegram Section */}
|
||||
<section className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<section className="bg-white dark:bg-gray-900 rounded-2xl p-4 shadow-sm dark:shadow-none border border-transparent dark:border-gray-800">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center">
|
||||
<MessageCircle className="text-blue-600" size={20} />
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<MessageCircle className="text-blue-600 dark:text-blue-400" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold">Telegram</h2>
|
||||
<p className="text-sm text-gray-500">Получай уведомления в Telegram</p>
|
||||
<h2 className="font-semibold dark:text-white">Telegram</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Получай уведомления в Telegram</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 bg-blue-50 rounded-xl">
|
||||
<p className="text-sm text-blue-800 mb-2">
|
||||
1. Напиши <code className="bg-blue-100 px-1 rounded">/start</code> боту в Telegram
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-xl">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300 mb-2">
|
||||
1. Напиши <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">/start</code> боту в Telegram
|
||||
</p>
|
||||
<button
|
||||
onClick={copyInstruction}
|
||||
className="flex items-center gap-2 text-sm font-medium text-blue-600 hover:text-blue-700"
|
||||
className="flex items-center gap-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
|
||||
>
|
||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||
{copied ? "Скопировано!" : "@pulse_tracking_bot"}
|
||||
</button>
|
||||
<p className="text-sm text-blue-800 mt-2">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300 mt-2">
|
||||
2. Скопируй Chat ID из ответа бота и вставь ниже
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Chat ID
|
||||
</label>
|
||||
<input
|
||||
@@ -189,20 +232,20 @@ export default function Settings() {
|
||||
</section>
|
||||
|
||||
{/* Notifications Section */}
|
||||
<section className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<section className="bg-white dark:bg-gray-900 rounded-2xl p-4 shadow-sm dark:shadow-none border border-transparent dark:border-gray-800">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-orange-100 flex items-center justify-center">
|
||||
<Bell className="text-orange-600" size={20} />
|
||||
<div className="w-10 h-10 rounded-xl bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center">
|
||||
<Bell className="text-orange-600 dark:text-orange-400" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold">Уведомления</h2>
|
||||
<p className="text-sm text-gray-500">Настрой ежедневные уведомления</p>
|
||||
<h2 className="font-semibold dark:text-white">Уведомления</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Настрой ежедневные уведомления</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center justify-between p-3 bg-gray-50 rounded-xl cursor-pointer">
|
||||
<span className="text-sm font-medium">Включить уведомления</span>
|
||||
<label className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-xl cursor-pointer">
|
||||
<span className="text-sm font-medium dark:text-white">Включить уведомления</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -210,42 +253,42 @@ export default function Settings() {
|
||||
onChange={(e) => setNotificationsEnabled(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-300 rounded-full peer peer-checked:bg-primary-500 transition-colors"></div>
|
||||
<div className="w-11 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-primary-500 transition-colors"></div>
|
||||
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full peer-checked:translate-x-5 transition-transform"></div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{notificationsEnabled && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 p-3 bg-yellow-50 rounded-xl">
|
||||
<Sun className="text-yellow-600" size={20} />
|
||||
<div className="flex items-center gap-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-xl">
|
||||
<Sun className="text-yellow-600 dark:text-yellow-400" size={20} />
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Утреннее уведомление
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">Задачи и привычки на сегодня</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Задачи и привычки на сегодня</p>
|
||||
</div>
|
||||
<input
|
||||
type="time"
|
||||
value={morningTime}
|
||||
onChange={(e) => setMorningTime(e.target.value)}
|
||||
className="px-3 py-1.5 border border-gray-200 rounded-lg text-sm"
|
||||
className="px-3 py-1.5 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-white rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-indigo-50 rounded-xl">
|
||||
<Moon className="text-indigo-600" size={20} />
|
||||
<div className="flex items-center gap-3 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-xl">
|
||||
<Moon className="text-indigo-600 dark:text-indigo-400" size={20} />
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Вечернее уведомление
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">Итоги дня: выполнено / осталось</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Итоги дня: выполнено / осталось</p>
|
||||
</div>
|
||||
<input
|
||||
type="time"
|
||||
value={eveningTime}
|
||||
onChange={(e) => setEveningTime(e.target.value)}
|
||||
className="px-3 py-1.5 border border-gray-200 rounded-lg text-sm"
|
||||
className="px-3 py-1.5 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-white rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -254,14 +297,14 @@ export default function Settings() {
|
||||
</section>
|
||||
|
||||
{/* Timezone Section */}
|
||||
<section className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<section className="bg-white dark:bg-gray-900 rounded-2xl p-4 shadow-sm dark:shadow-none border border-transparent dark:border-gray-800">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
|
||||
<Globe className="text-purple-600" size={20} />
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||
<Globe className="text-purple-600 dark:text-purple-400" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold">Часовой пояс</h2>
|
||||
<p className="text-sm text-gray-500">Для корректных напоминаний</p>
|
||||
<h2 className="font-semibold dark:text-white">Часовой пояс</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Для корректных напоминаний</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -291,7 +334,7 @@ export default function Settings() {
|
||||
)}
|
||||
|
||||
{mutation.isSuccess && !hasChanges && (
|
||||
<div className="p-3 rounded-xl bg-green-50 text-green-700 text-sm text-center">
|
||||
<div className="p-3 rounded-xl bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 text-sm text-center">
|
||||
✅ Настройки сохранены
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,374 +1,691 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ChevronLeft, ChevronRight, Flame, Target, TrendingUp, BarChart3 } from 'lucide-react'
|
||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isToday, subMonths, addMonths, parseISO, isSameMonth } from 'date-fns'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { ChevronDown, Flame, Trophy, CheckCircle2, TrendingUp, BarChart3, Calendar, Sparkles, Target, Zap } from 'lucide-react'
|
||||
import { format, subDays, parseISO, startOfDay, differenceInDays, isBefore, isAfter, eachDayOfInterval, startOfMonth, getDay, addDays } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, Area, AreaChart, CartesianGrid } from 'recharts'
|
||||
import { habitsApi } from '../api/habits'
|
||||
import Navigation from '../components/Navigation'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// Получить дату начала привычки
|
||||
function getHabitStartDate(habit) {
|
||||
if (habit.start_date) return startOfDay(parseISO(habit.start_date))
|
||||
if (habit.created_at) return startOfDay(parseISO(habit.created_at))
|
||||
return startOfDay(new Date())
|
||||
}
|
||||
|
||||
// Check if habit is frozen on date
|
||||
function isHabitFrozenOnDate(freezes, date) {
|
||||
if (!freezes || freezes.length === 0) return false
|
||||
const checkDate = startOfDay(date)
|
||||
return freezes.some(freeze => {
|
||||
const start = startOfDay(parseISO(freeze.start_date))
|
||||
const end = startOfDay(parseISO(freeze.end_date))
|
||||
return !isBefore(checkDate, start) && !isAfter(checkDate, end)
|
||||
})
|
||||
}
|
||||
|
||||
// Проверить ожидается ли привычка в дату
|
||||
function isHabitExpectedOnDate(habit, date, freezes) {
|
||||
const checkDate = startOfDay(date)
|
||||
const startDate = getHabitStartDate(habit)
|
||||
if (checkDate < startDate || checkDate > startOfDay(new Date())) return false
|
||||
if (isHabitFrozenOnDate(freezes, date)) return false
|
||||
|
||||
const dayOfWeek = checkDate.getDay() || 7
|
||||
if (habit.frequency === "daily") return true
|
||||
if (habit.frequency === "weekly") {
|
||||
if (habit.target_days?.length > 0) return habit.target_days.includes(dayOfWeek)
|
||||
return true
|
||||
}
|
||||
if (habit.frequency === "interval" && habit.target_count > 0) {
|
||||
const daysSinceStart = differenceInDays(checkDate, startDate)
|
||||
return daysSinceStart % habit.target_count === 0
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Custom Tooltip Component
|
||||
const CustomTooltip = ({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-gray-900/95 backdrop-blur-sm px-4 py-3 rounded-xl shadow-2xl border border-gray-700/50">
|
||||
<p className="text-gray-400 text-xs mb-1">{label}</p>
|
||||
<p className="text-white font-bold text-lg">{payload[0].value}%</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Stat Card Component
|
||||
const StatCard = ({ icon, value, label, emoji, color, delay = 0 }) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ delay, duration: 0.4, ease: "easeOut" }}
|
||||
className="relative overflow-hidden group"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/80 to-white/40 dark:from-gray-800/80 dark:to-gray-900/40 backdrop-blur-xl rounded-2xl" />
|
||||
<div className={clsx(
|
||||
"absolute inset-0 opacity-[0.03] group-hover:opacity-[0.06] transition-opacity duration-500",
|
||||
`bg-gradient-to-br ${color}`
|
||||
)} />
|
||||
<div className="relative p-5 flex items-center gap-4">
|
||||
<div className={clsx(
|
||||
"w-14 h-14 rounded-2xl flex items-center justify-center text-2xl",
|
||||
"bg-gradient-to-br shadow-lg",
|
||||
color
|
||||
)}>
|
||||
{emoji}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">
|
||||
{value}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r opacity-50 rounded-b-2xl"
|
||||
style={{ background: `linear-gradient(to right, var(--tw-gradient-stops))` }} />
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
// Heatmap Cell Component
|
||||
const HeatmapCell = ({ day, getColor, index }) => {
|
||||
const [showTooltip, setShowTooltip] = useState(false)
|
||||
const cellRef = useRef(null)
|
||||
|
||||
return (
|
||||
<div className="relative" ref={cellRef}>
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: index * 0.003, duration: 0.2 }}
|
||||
className={clsx(
|
||||
"w-full aspect-square rounded-[4px] cursor-pointer transition-all duration-200",
|
||||
"hover:ring-2 hover:ring-primary-400 hover:ring-offset-2 hover:ring-offset-gray-900",
|
||||
"hover:scale-110 hover:z-10",
|
||||
getColor(day.count, day.expected)
|
||||
)}
|
||||
onMouseEnter={() => setShowTooltip(true)}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{showTooltip && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 5, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 5, scale: 0.9 }}
|
||||
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50 pointer-events-none"
|
||||
>
|
||||
<div className="bg-gray-900 text-white px-3 py-2 rounded-lg shadow-xl text-xs whitespace-nowrap">
|
||||
<p className="font-medium">{format(day.date, 'd MMMM', { locale: ru })}</p>
|
||||
<p className="text-primary-400 mt-0.5">
|
||||
{day.count}/{day.expected} выполнено
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Section Header Component
|
||||
const SectionHeader = ({ icon: Icon, title, subtitle }) => (
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500/20 to-primary-600/10 flex items-center justify-center">
|
||||
<Icon className="text-primary-500" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white">{title}</h3>
|
||||
{subtitle && <p className="text-xs text-gray-500 dark:text-gray-400">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default function Stats() {
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date())
|
||||
const [selectedHabitId, setSelectedHabitId] = useState(null)
|
||||
const [allHabitLogs, setAllHabitLogs] = useState({})
|
||||
const [allHabitStats, setAllHabitStats] = useState({})
|
||||
const [selectedHabitId, setSelectedHabitId] = useState(null)
|
||||
const [allHabitFreezes, setAllHabitFreezes] = useState({})
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
|
||||
const { data: habits = [] } = useQuery({
|
||||
queryKey: ['habits'],
|
||||
queryFn: habitsApi.list,
|
||||
})
|
||||
|
||||
// Загрузка логов и статистики для всех привычек
|
||||
useEffect(() => {
|
||||
if (habits.length > 0) {
|
||||
loadAllHabitsData()
|
||||
}
|
||||
if (habits.length > 0) loadAllHabitsData()
|
||||
}, [habits])
|
||||
|
||||
const loadAllHabitsData = async () => {
|
||||
const logsMap = {}
|
||||
const statsMap = {}
|
||||
const freezesMap = {}
|
||||
|
||||
await Promise.all(habits.map(async (habit) => {
|
||||
try {
|
||||
const [logs, stats] = await Promise.all([
|
||||
const [logs, stats, freezes] = await Promise.all([
|
||||
habitsApi.getLogs(habit.id, 90),
|
||||
habitsApi.getHabitStats(habit.id),
|
||||
habitsApi.getFreezes(habit.id),
|
||||
])
|
||||
logsMap[habit.id] = logs
|
||||
statsMap[habit.id] = stats
|
||||
freezesMap[habit.id] = freezes
|
||||
} catch (e) {
|
||||
console.error(`Error loading data for habit ${habit.id}:`, e)
|
||||
logsMap[habit.id] = []
|
||||
statsMap[habit.id] = null
|
||||
freezesMap[habit.id] = []
|
||||
}
|
||||
}))
|
||||
|
||||
setAllHabitLogs(logsMap)
|
||||
setAllHabitStats(statsMap)
|
||||
setAllHabitFreezes(freezesMap)
|
||||
}
|
||||
|
||||
const monthStart = startOfMonth(currentMonth)
|
||||
const monthEnd = endOfMonth(currentMonth)
|
||||
const daysInMonth = eachDayOfInterval({ start: monthStart, end: monthEnd })
|
||||
|
||||
const startDayOfWeek = monthStart.getDay()
|
||||
const paddingDays = startDayOfWeek === 0 ? 6 : startDayOfWeek - 1
|
||||
|
||||
// Получить привычки, выполненные в конкретный день
|
||||
const getCompletedHabitsForDate = (date) => {
|
||||
const dateStr = format(date, 'yyyy-MM-dd')
|
||||
return habits.filter(habit => {
|
||||
// Combined stats for all or selected habit
|
||||
const computedStats = useMemo(() => {
|
||||
const targetHabits = selectedHabitId
|
||||
? habits.filter(h => h.id === selectedHabitId)
|
||||
: habits
|
||||
|
||||
let totalLogs = 0
|
||||
let totalExpected = 0
|
||||
let currentStreak = 0
|
||||
let bestStreak = 0
|
||||
|
||||
targetHabits.forEach(habit => {
|
||||
const logs = allHabitLogs[habit.id] || []
|
||||
return logs.some(log => log.date.split('T')[0] === dateStr)
|
||||
const stats = allHabitStats[habit.id]
|
||||
const freezes = allHabitFreezes[habit.id] || []
|
||||
|
||||
totalLogs += logs.length
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const date = subDays(new Date(), i)
|
||||
if (isHabitExpectedOnDate(habit, date, freezes)) totalExpected++
|
||||
}
|
||||
|
||||
if (stats) {
|
||||
currentStreak = Math.max(currentStreak, stats.current_streak || 0)
|
||||
bestStreak = Math.max(bestStreak, stats.longest_streak || 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const rate = totalExpected > 0 ? Math.round((totalLogs / totalExpected) * 100) : 0
|
||||
|
||||
return { totalLogs, currentStreak, bestStreak, rate }
|
||||
}, [selectedHabitId, habits, allHabitLogs, allHabitStats, allHabitFreezes])
|
||||
|
||||
// Подсчёт выполнений за текущий месяц для каждой привычки
|
||||
const monthlyStats = useMemo(() => {
|
||||
const stats = {}
|
||||
habits.forEach(habit => {
|
||||
const logs = allHabitLogs[habit.id] || []
|
||||
const monthLogs = logs.filter(log => {
|
||||
const logDate = parseISO(log.date.split('T')[0])
|
||||
return isSameMonth(logDate, currentMonth)
|
||||
// Heatmap data (12 weeks = 84 days)
|
||||
const heatmapData = useMemo(() => {
|
||||
const today = startOfDay(new Date())
|
||||
const startDate = subDays(today, 83)
|
||||
const days = eachDayOfInterval({ start: startDate, end: today })
|
||||
|
||||
// Align to week start (Monday)
|
||||
const firstDayOfWeek = getDay(startDate) || 7
|
||||
const paddingDays = firstDayOfWeek - 1
|
||||
|
||||
return days.map(day => {
|
||||
const dateStr = format(day, 'yyyy-MM-dd')
|
||||
let count = 0
|
||||
let expected = 0
|
||||
|
||||
const targetHabits = selectedHabitId
|
||||
? habits.filter(h => h.id === selectedHabitId)
|
||||
: habits
|
||||
|
||||
targetHabits.forEach(habit => {
|
||||
const logs = allHabitLogs[habit.id] || []
|
||||
const freezes = allHabitFreezes[habit.id] || []
|
||||
|
||||
if (logs.some(l => l.date.split('T')[0] === dateStr)) count++
|
||||
if (isHabitExpectedOnDate(habit, day, freezes)) expected++
|
||||
})
|
||||
stats[habit.id] = monthLogs.length
|
||||
|
||||
return { date: day, dateStr, count, expected }
|
||||
})
|
||||
return stats
|
||||
}, [habits, allHabitLogs, currentMonth])
|
||||
}, [selectedHabitId, habits, allHabitLogs, allHabitFreezes])
|
||||
|
||||
// Общий % выполнения за месяц
|
||||
const overallMonthlyPercent = useMemo(() => {
|
||||
if (habits.length === 0) return 0
|
||||
const totalPossible = habits.length * daysInMonth.length
|
||||
const totalCompleted = Object.values(monthlyStats).reduce((sum, val) => sum + val, 0)
|
||||
return Math.round((totalCompleted / totalPossible) * 100)
|
||||
}, [habits, monthlyStats, daysInMonth])
|
||||
// Get unique months for heatmap labels
|
||||
const heatmapMonths = useMemo(() => {
|
||||
const months = []
|
||||
let currentMonth = null
|
||||
heatmapData.forEach((day, index) => {
|
||||
const month = format(day.date, 'MMM', { locale: ru })
|
||||
if (month !== currentMonth) {
|
||||
months.push({ month, index: Math.floor(index / 7) })
|
||||
currentMonth = month
|
||||
}
|
||||
})
|
||||
return months
|
||||
}, [heatmapData])
|
||||
|
||||
const prevMonth = () => setCurrentMonth(subMonths(currentMonth, 1))
|
||||
const nextMonth = () => setCurrentMonth(addMonths(currentMonth, 1))
|
||||
// Line chart data (30 days completion rate)
|
||||
const lineChartData = useMemo(() => {
|
||||
const data = []
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const date = subDays(new Date(), i)
|
||||
const dateStr = format(date, 'yyyy-MM-dd')
|
||||
let completed = 0
|
||||
let expected = 0
|
||||
|
||||
const targetHabits = selectedHabitId
|
||||
? habits.filter(h => h.id === selectedHabitId)
|
||||
: habits
|
||||
|
||||
targetHabits.forEach(habit => {
|
||||
const logs = allHabitLogs[habit.id] || []
|
||||
const freezes = allHabitFreezes[habit.id] || []
|
||||
|
||||
if (logs.some(l => l.date.split('T')[0] === dateStr)) completed++
|
||||
if (isHabitExpectedOnDate(habit, date, freezes)) expected++
|
||||
})
|
||||
|
||||
const rate = expected > 0 ? Math.round((completed / expected) * 100) : 0
|
||||
data.push({ date: format(date, 'dd.MM'), fullDate: format(date, 'd MMM', { locale: ru }), rate, completed, expected })
|
||||
}
|
||||
return data
|
||||
}, [selectedHabitId, habits, allHabitLogs, allHabitFreezes])
|
||||
|
||||
// Bar chart data (habits comparison)
|
||||
const barChartData = useMemo(() => {
|
||||
return habits.map(habit => {
|
||||
const logs = allHabitLogs[habit.id] || []
|
||||
const freezes = allHabitFreezes[habit.id] || []
|
||||
|
||||
let expected = 0
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const date = subDays(new Date(), i)
|
||||
if (isHabitExpectedOnDate(habit, date, freezes)) expected++
|
||||
}
|
||||
|
||||
const completed = logs.filter(l => {
|
||||
const logDate = parseISO(l.date.split('T')[0])
|
||||
return differenceInDays(new Date(), logDate) < 30
|
||||
}).length
|
||||
|
||||
const rate = expected > 0 ? Math.round((completed / expected) * 100) : 0
|
||||
|
||||
return {
|
||||
name: habit.name,
|
||||
icon: habit.icon,
|
||||
rate,
|
||||
color: habit.color || '#0d9488',
|
||||
completed,
|
||||
expected
|
||||
}
|
||||
}).sort((a, b) => b.rate - a.rate)
|
||||
}, [habits, allHabitLogs, allHabitFreezes])
|
||||
|
||||
// Heatmap intensity color - 5 levels
|
||||
const getHeatmapColor = (count, expected) => {
|
||||
if (expected === 0) return 'bg-[#1a1a1a]'
|
||||
const ratio = count / expected
|
||||
if (ratio === 0) return 'bg-[#1f1f1f] dark:bg-[#1a1a1a]'
|
||||
if (ratio < 0.4) return 'bg-teal-900/80'
|
||||
if (ratio < 0.7) return 'bg-teal-700'
|
||||
if (ratio < 1) return 'bg-teal-600'
|
||||
return 'bg-teal-400 shadow-sm shadow-teal-400/30'
|
||||
}
|
||||
|
||||
const selectedHabit = habits.find(h => h.id === selectedHabitId)
|
||||
const selectedStats = selectedHabitId ? allHabitStats[selectedHabitId] : null
|
||||
|
||||
// Group heatmap by weeks (columns)
|
||||
const heatmapWeeks = useMemo(() => {
|
||||
const weeks = []
|
||||
for (let i = 0; i < heatmapData.length; i += 7) {
|
||||
weeks.push(heatmapData.slice(i, i + 7))
|
||||
}
|
||||
return weeks
|
||||
}, [heatmapData])
|
||||
|
||||
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">
|
||||
<h1 className="text-xl font-display font-bold text-gray-900">Статистика</h1>
|
||||
<p className="text-sm text-gray-500">Общий прогресс по привычкам</p>
|
||||
<div className="min-h-screen bg-gray-950 pb-24 transition-colors duration-300">
|
||||
{/* Gradient Background */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-96 h-96 bg-primary-500/10 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-0 w-80 h-80 bg-teal-500/5 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<header className="relative bg-gray-900/50 backdrop-blur-xl border-b border-gray-800/50 sticky top-0 z-20">
|
||||
<div className="max-w-lg mx-auto px-5 py-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-11 h-11 rounded-2xl bg-gradient-to-br from-primary-500 to-teal-600 flex items-center justify-center shadow-lg shadow-primary-500/25">
|
||||
<BarChart3 className="text-white" size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
Статистика
|
||||
<Sparkles className="text-primary-400" size={16} />
|
||||
</h1>
|
||||
<p className="text-sm text-gray-400">Отслеживай свой прогресс</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-lg mx-auto px-4 py-6 space-y-6">
|
||||
<main className="relative max-w-lg mx-auto px-5 py-6 space-y-8">
|
||||
|
||||
{/* Легенда с привычками */}
|
||||
{habits.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="card p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Привычки</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">Общий прогресс:</span>
|
||||
<span className="text-sm font-bold text-primary-600">{overallMonthlyPercent}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{habits.map((habit) => (
|
||||
<button
|
||||
key={habit.id}
|
||||
onClick={() => setSelectedHabitId(selectedHabitId === habit.id ? null : habit.id)}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm transition-all',
|
||||
selectedHabitId === habit.id
|
||||
? 'ring-2 ring-offset-1'
|
||||
: 'hover:bg-gray-100'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: selectedHabitId === habit.id ? habit.color + '20' : undefined,
|
||||
ringColor: habit.color,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: habit.color }}
|
||||
/>
|
||||
<span className="text-gray-700">{habit.icon}</span>
|
||||
<span className="text-gray-600 font-medium">{habit.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Календарь */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
{/* Habit Selector Dropdown */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="card p-5"
|
||||
className="relative"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-all"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<h3 className="text-lg font-semibold text-gray-900 capitalize">
|
||||
{format(currentMonth, 'LLLL yyyy', { locale: ru })}
|
||||
</h3>
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-all"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Дни недели */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map((day) => (
|
||||
<div key={day} className="text-center text-xs font-medium text-gray-400 py-2">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Дни месяца */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{[...Array(paddingDays)].map((_, i) => (
|
||||
<div key={'pad' + i} className="aspect-square" />
|
||||
))}
|
||||
|
||||
{daysInMonth.map((day) => {
|
||||
const completedHabits = getCompletedHabitsForDate(day)
|
||||
const today = isToday(day)
|
||||
const future = day > new Date()
|
||||
const completedCount = completedHabits.length
|
||||
const totalCount = habits.length
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
<button
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
className="w-full bg-gray-900/80 backdrop-blur-xl border border-gray-800 rounded-2xl p-4 flex items-center justify-between hover:border-gray-700 transition-all duration-300"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedHabit ? (
|
||||
<>
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center text-xl"
|
||||
style={{ backgroundColor: selectedHabit.color + '20' }}
|
||||
>
|
||||
{selectedHabit.icon}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className="font-semibold text-white block">{selectedHabit.name}</span>
|
||||
<span className="text-xs text-gray-500">Выбранная привычка</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500/20 to-teal-500/10 flex items-center justify-center">
|
||||
<Target className="text-primary-400" size={20} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className="font-semibold text-white block">Все привычки</span>
|
||||
<span className="text-xs text-gray-500">{habits.length} привычек</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className={clsx(
|
||||
"text-gray-500 transition-transform duration-300",
|
||||
dropdownOpen && "rotate-180"
|
||||
)} size={20} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{dropdownOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute top-full left-0 right-0 mt-2 bg-gray-900 backdrop-blur-xl rounded-2xl shadow-2xl border border-gray-800 z-30 overflow-hidden max-h-80 overflow-y-auto"
|
||||
>
|
||||
<button
|
||||
onClick={() => { setSelectedHabitId(null); setDropdownOpen(false) }}
|
||||
className={clsx(
|
||||
'aspect-square flex flex-col items-center justify-center rounded-lg relative p-1',
|
||||
today && 'ring-2 ring-primary-500 bg-primary-50',
|
||||
future && 'opacity-40',
|
||||
!today && completedCount > 0 && 'bg-gray-50'
|
||||
"w-full p-4 flex items-center gap-3 hover:bg-gray-800 transition-colors border-b border-gray-800/50",
|
||||
!selectedHabitId && "bg-primary-500/10"
|
||||
)}
|
||||
>
|
||||
{/* Число дня */}
|
||||
<span className={clsx(
|
||||
'text-sm font-medium',
|
||||
today ? 'text-primary-600' : completedCount > 0 ? 'text-gray-900' : 'text-gray-400'
|
||||
)}>
|
||||
{format(day, 'd')}
|
||||
</span>
|
||||
|
||||
{/* Цветные точки выполненных привычек */}
|
||||
{completedCount > 0 && (
|
||||
<div className="flex flex-wrap gap-0.5 justify-center mt-0.5">
|
||||
{completedHabits.slice(0, 4).map((habit) => (
|
||||
<motion.div
|
||||
key={habit.id}
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: habit.color }}
|
||||
/>
|
||||
))}
|
||||
{completedHabits.length > 4 && (
|
||||
<span className="text-[8px] text-gray-400">+{completedHabits.length - 4}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* X/Y в углу */}
|
||||
{totalCount > 0 && !future && (
|
||||
<span className={clsx(
|
||||
'absolute bottom-0.5 right-0.5 text-[8px] font-medium',
|
||||
completedCount === totalCount ? 'text-green-500' :
|
||||
completedCount > 0 ? 'text-gray-400' : 'text-gray-300'
|
||||
)}>
|
||||
{completedCount}/{totalCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Target className="text-primary-400" size={20} />
|
||||
<span className="font-medium text-white">Все привычки</span>
|
||||
{!selectedHabitId && <div className="ml-auto w-2 h-2 rounded-full bg-primary-400" />}
|
||||
</button>
|
||||
{habits.map(habit => (
|
||||
<button
|
||||
key={habit.id}
|
||||
onClick={() => { setSelectedHabitId(habit.id); setDropdownOpen(false) }}
|
||||
className={clsx(
|
||||
"w-full p-4 flex items-center gap-3 hover:bg-gray-800 transition-colors",
|
||||
selectedHabitId === habit.id && "bg-primary-500/10"
|
||||
)}
|
||||
>
|
||||
<span className="text-xl">{habit.icon}</span>
|
||||
<span className="font-medium text-white">{habit.name}</span>
|
||||
{selectedHabitId === habit.id && <div className="ml-auto w-2 h-2 rounded-full bg-primary-400" />}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Monthly Summary - карточки для каждой привычки */}
|
||||
{habits.length > 0 && (
|
||||
<motion.div
|
||||
{/* Stats Cards */}
|
||||
<section>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<StatCard
|
||||
emoji="🔥"
|
||||
value={computedStats.currentStreak}
|
||||
label="Текущий streak"
|
||||
color="from-orange-500/20 to-red-500/10"
|
||||
delay={0}
|
||||
/>
|
||||
<StatCard
|
||||
emoji="🏆"
|
||||
value={computedStats.bestStreak}
|
||||
label="Лучший streak"
|
||||
color="from-yellow-500/20 to-amber-500/10"
|
||||
delay={0.1}
|
||||
/>
|
||||
<StatCard
|
||||
emoji="✅"
|
||||
value={computedStats.totalLogs}
|
||||
label="Всего выполнено"
|
||||
color="from-green-500/20 to-emerald-500/10"
|
||||
delay={0.2}
|
||||
/>
|
||||
<StatCard
|
||||
emoji="📈"
|
||||
value={`${computedStats.rate}%`}
|
||||
label="Completion rate"
|
||||
color="from-primary-500/20 to-teal-500/10"
|
||||
delay={0.3}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Heatmap Calendar */}
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-gray-900/60 backdrop-blur-xl border border-gray-800/50 rounded-3xl p-6"
|
||||
>
|
||||
<SectionHeader
|
||||
icon={Calendar}
|
||||
title="Активность"
|
||||
subtitle="Последние 12 недель"
|
||||
/>
|
||||
|
||||
{/* Month labels */}
|
||||
<div className="flex mb-2 ml-8">
|
||||
{heatmapMonths.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-[10px] text-gray-500 capitalize"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${32 + m.index * 18}px`
|
||||
}}
|
||||
>
|
||||
{m.month}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 mt-6">
|
||||
{/* Day labels */}
|
||||
<div className="flex flex-col gap-[3px] pr-2">
|
||||
{['Пн', '', 'Ср', '', 'Пт', '', 'Вс'].map((d, i) => (
|
||||
<div key={i} className="h-[14px] text-[10px] text-gray-500 flex items-center">
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Heatmap grid */}
|
||||
<div className="flex gap-[3px] flex-1">
|
||||
{heatmapWeeks.map((week, weekIndex) => (
|
||||
<div key={weekIndex} className="flex flex-col gap-[3px] flex-1">
|
||||
{week.map((day, dayIndex) => (
|
||||
<HeatmapCell
|
||||
key={day.dateStr}
|
||||
day={day}
|
||||
getColor={getHeatmapColor}
|
||||
index={weekIndex * 7 + dayIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center justify-end gap-2 mt-5 pt-4 border-t border-gray-800/50">
|
||||
<span className="text-xs text-gray-500">Меньше</span>
|
||||
<div className="flex gap-1">
|
||||
<div className="w-3.5 h-3.5 rounded-[3px] bg-[#1f1f1f]" />
|
||||
<div className="w-3.5 h-3.5 rounded-[3px] bg-teal-900/80" />
|
||||
<div className="w-3.5 h-3.5 rounded-[3px] bg-teal-700" />
|
||||
<div className="w-3.5 h-3.5 rounded-[3px] bg-teal-600" />
|
||||
<div className="w-3.5 h-3.5 rounded-[3px] bg-teal-400" />
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">Больше</span>
|
||||
</div>
|
||||
</motion.section>
|
||||
|
||||
{/* Line Chart - Completion Rate */}
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="bg-gray-900/60 backdrop-blur-xl border border-gray-800/50 rounded-3xl p-6"
|
||||
>
|
||||
<SectionHeader
|
||||
icon={TrendingUp}
|
||||
title="Completion Rate"
|
||||
subtitle="Динамика за 30 дней"
|
||||
/>
|
||||
|
||||
<div className="h-56">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={lineChartData}>
|
||||
<defs>
|
||||
<linearGradient id="colorRate" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#0d9488" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="#0d9488" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="#374151"
|
||||
strokeOpacity={0.3}
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 10, fill: '#6b7280' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: '#6b7280' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
domain={[0, 100]}
|
||||
width={30}
|
||||
tickFormatter={(v) => `${v}%`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="rate"
|
||||
stroke="#0d9488"
|
||||
strokeWidth={2.5}
|
||||
fill="url(#colorRate)"
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
fill: '#0d9488',
|
||||
stroke: '#fff',
|
||||
strokeWidth: 2,
|
||||
className: 'drop-shadow-lg'
|
||||
}}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</motion.section>
|
||||
|
||||
{/* Bar Chart - Habits Comparison */}
|
||||
{!selectedHabitId && habits.length > 1 && (
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
className="card p-5"
|
||||
transition={{ delay: 0.4 }}
|
||||
className="bg-gray-900/60 backdrop-blur-xl border border-gray-800/50 rounded-3xl p-6"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-primary-500" />
|
||||
Итоги месяца
|
||||
</h3>
|
||||
<SectionHeader
|
||||
icon={BarChart3}
|
||||
title="По привычкам"
|
||||
subtitle="Рейтинг за 30 дней"
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
{habits.map((habit) => {
|
||||
const completed = monthlyStats[habit.id] || 0
|
||||
const total = daysInMonth.length
|
||||
const percent = Math.round((completed / total) * 100)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={habit.id}
|
||||
className="p-3 rounded-xl bg-gray-50 hover:bg-gray-100 transition-all cursor-pointer"
|
||||
onClick={() => setSelectedHabitId(selectedHabitId === habit.id ? null : habit.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: habit.color }}
|
||||
/>
|
||||
<span className="text-lg">{habit.icon}</span>
|
||||
<span className="font-medium text-gray-900">{habit.name}</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-gray-700">{completed}/{total}</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${percent}%` }}
|
||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||
className="h-full rounded-full"
|
||||
style={{ backgroundColor: habit.color }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right mt-1">
|
||||
<span className="text-xs text-gray-500">{percent}%</span>
|
||||
</div>
|
||||
{barChartData.map((habit, index) => (
|
||||
<motion.div
|
||||
key={habit.name}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 * index }}
|
||||
className="relative"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-lg">{habit.icon}</span>
|
||||
<span className="text-sm font-medium text-white flex-1 truncate">{habit.name}</span>
|
||||
<span className="text-sm font-bold text-primary-400">{habit.rate}%</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${habit.rate}%` }}
|
||||
transition={{ delay: 0.2 + 0.1 * index, duration: 0.8, ease: "easeOut" }}
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
backgroundColor: habit.color,
|
||||
boxShadow: `0 0 10px ${habit.color}50`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className="text-[10px] text-gray-500">{habit.completed} выполнено</span>
|
||||
<span className="text-[10px] text-gray-500">{habit.expected} ожидалось</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Детальная статистика для выбранной привычки */}
|
||||
{selectedHabit && selectedStats && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="card p-5"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center text-xl"
|
||||
style={{ backgroundColor: selectedHabit.color + '20' }}
|
||||
>
|
||||
{selectedHabit.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{selectedHabit.name}</h3>
|
||||
<p className="text-sm text-gray-500">Детальная статистика</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Streak карточки */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
<div className="text-center p-3 bg-gradient-to-br from-accent-50 to-accent-100 rounded-xl">
|
||||
<Flame className="w-5 h-5 text-accent-500 mx-auto mb-1" />
|
||||
<p className="text-xl font-bold text-gray-900">{selectedStats.current_streak}</p>
|
||||
<p className="text-xs text-gray-500">Текущий</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-gradient-to-br from-primary-50 to-primary-100 rounded-xl">
|
||||
<TrendingUp className="w-5 h-5 text-primary-500 mx-auto mb-1" />
|
||||
<p className="text-xl font-bold text-gray-900">{selectedStats.longest_streak}</p>
|
||||
<p className="text-xs text-gray-500">Лучший</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-gradient-to-br from-green-50 to-green-100 rounded-xl">
|
||||
<Target className="w-5 h-5 text-green-500 mx-auto mb-1" />
|
||||
<p className="text-xl font-bold text-gray-900">{Math.round(selectedStats.completion_pct || 0)}%</p>
|
||||
<p className="text-xs text-gray-500">Выполнение</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Детальные данные */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center py-2 border-b border-gray-100">
|
||||
<span className="text-gray-600">Всего выполнений</span>
|
||||
<span className="font-semibold text-gray-900">{selectedStats.total_logs || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b border-gray-100">
|
||||
<span className="text-gray-600">За эту неделю</span>
|
||||
<span className="font-semibold text-gray-900">{selectedStats.this_week || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b border-gray-100">
|
||||
<span className="text-gray-600">За этот месяц</span>
|
||||
<span className="font-semibold text-gray-900">{selectedStats.this_month || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-gray-600">Дата создания</span>
|
||||
<span className="font-semibold text-gray-900">
|
||||
{format(parseISO(selectedHabit.created_at), 'd MMM yyyy', { locale: ru })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.section>
|
||||
)}
|
||||
|
||||
{habits.length === 0 && (
|
||||
<div className="card p-10 text-center">
|
||||
<p className="text-gray-500">Создайте привычки, чтобы видеть статистику</p>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="bg-gray-900/60 backdrop-blur-xl border border-gray-800/50 rounded-3xl p-12 text-center"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-2xl bg-gray-800 flex items-center justify-center mx-auto mb-4">
|
||||
<Zap className="text-gray-600" size={32} />
|
||||
</div>
|
||||
<p className="text-gray-400 font-medium">Создайте привычки,</p>
|
||||
<p className="text-gray-500 text-sm">чтобы видеть статистику</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { Plus, Check, Circle, Calendar, AlertTriangle, Undo2, Edit2, Repeat } from 'lucide-react'
|
||||
import { format, parseISO, isToday, isTomorrow, isPast } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
import { tasksApi } from '../api/tasks'
|
||||
@@ -12,9 +12,16 @@ 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' },
|
||||
1: { label: 'Низкий', class: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
2: { label: 'Средний', class: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' },
|
||||
3: { label: 'Высокий', class: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' },
|
||||
}
|
||||
|
||||
const RECURRENCE_LABELS = {
|
||||
daily: 'Ежедневно',
|
||||
weekly: 'Еженедельно',
|
||||
monthly: 'Ежемесячно',
|
||||
custom: 'Повтор',
|
||||
}
|
||||
|
||||
function formatDueDate(dateStr) {
|
||||
@@ -28,7 +35,7 @@ function formatDueDate(dateStr) {
|
||||
export default function Tasks() {
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [editingTask, setEditingTask] = useState(null)
|
||||
const [filter, setFilter] = useState('active') // all, active, completed
|
||||
const [filter, setFilter] = useState('active')
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: tasks = [], isLoading } = useQuery({
|
||||
@@ -56,31 +63,21 @@ export default function Tasks() {
|
||||
})
|
||||
|
||||
const handleToggle = (task) => {
|
||||
if (task.completed) {
|
||||
uncompleteMutation.mutate(task.id)
|
||||
} else {
|
||||
completeMutation.mutate(task.id)
|
||||
}
|
||||
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="min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300">
|
||||
<header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 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"
|
||||
>
|
||||
<h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">Задачи</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: 'Активные' },
|
||||
@@ -94,7 +91,7 @@ export default function Tasks() {
|
||||
'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'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
@@ -110,35 +107,28 @@ export default function Tasks() {
|
||||
{[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="w-10 h-10 rounded-xl bg-gray-200 dark:bg-gray-700" />
|
||||
<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 className="h-5 bg-gray-200 dark:bg-gray-700 rounded-lg w-3/4 mb-2" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 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" />
|
||||
<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 dark:from-primary-900/30 dark:to-primary-800/30 flex items-center justify-center mx-auto mb-5">
|
||||
<Check className="w-10 h-10 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<h3 className="text-xl font-display font-bold text-gray-900 mb-2">
|
||||
<h3 className="text-xl font-display font-bold text-gray-900 dark:text-white mb-2">
|
||||
{filter === 'active' ? 'Нет активных задач' : filter === 'completed' ? 'Нет выполненных задач' : 'Нет задач'}
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-6">
|
||||
{filter === 'active' ? 'Добавь новую задачу или выбери другой фильтр' : 'Выполняй задачи и они появятся здесь'}
|
||||
</p>
|
||||
{filter === 'active' && (
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<button onClick={() => setShowCreate(true)} className="btn btn-primary">
|
||||
<Plus size={18} />
|
||||
Добавить задачу
|
||||
</button>
|
||||
@@ -148,14 +138,7 @@ export default function Tasks() {
|
||||
<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}
|
||||
/>
|
||||
<TaskCard key={task.id} task={task} index={index} onToggle={() => handleToggle(task)} onEdit={() => setEditingTask(task)} isLoading={completeMutation.isPending || uncompleteMutation.isPending} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
@@ -163,17 +146,8 @@ export default function Tasks() {
|
||||
</main>
|
||||
|
||||
<Navigation />
|
||||
|
||||
<CreateTaskModal
|
||||
open={showCreate}
|
||||
onClose={() => setShowCreate(false)}
|
||||
/>
|
||||
|
||||
<EditTaskModal
|
||||
open={!!editingTask}
|
||||
onClose={() => setEditingTask(null)}
|
||||
task={editingTask}
|
||||
/>
|
||||
<CreateTaskModal open={showCreate} onClose={() => setShowCreate(false)} />
|
||||
<EditTaskModal open={!!editingTask} onClose={() => setEditingTask(null)} task={editingTask} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -187,10 +161,7 @@ function TaskCard({ task, index, onToggle, onEdit, isLoading }) {
|
||||
const handleCheck = (e) => {
|
||||
e.stopPropagation()
|
||||
if (isLoading) return
|
||||
if (!task.completed) {
|
||||
setShowConfetti(true)
|
||||
setTimeout(() => setShowConfetti(false), 1000)
|
||||
}
|
||||
if (!task.completed) { setShowConfetti(true); setTimeout(() => setShowConfetti(false), 1000) }
|
||||
onToggle()
|
||||
}
|
||||
|
||||
@@ -203,25 +174,9 @@ function TaskCard({ task, index, onToggle, onEdit, isLoading }) {
|
||||
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"
|
||||
>
|
||||
<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 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>
|
||||
)}
|
||||
@@ -233,21 +188,12 @@ function TaskCard({ task, index, onToggle, onEdit, 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'
|
||||
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'
|
||||
}}
|
||||
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 }}
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
@@ -256,57 +202,40 @@ function TaskCard({ task, index, onToggle, onEdit, isLoading }) {
|
||||
</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>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className={clsx("font-semibold truncate cursor-pointer hover:text-primary-600 dark:hover:text-primary-400", task.completed ? "text-gray-400 line-through" : "text-gray-900 dark:text-white")}>{task.title}</h3>
|
||||
{task.is_recurring && <span className="text-sm" title={RECURRENCE_LABELS[task.recurrence_type] || 'Повторяется'}>🔄</span>}
|
||||
</div>
|
||||
|
||||
{task.description && (
|
||||
<p className="text-sm text-gray-500 truncate mt-0.5">{task.description}</p>
|
||||
)}
|
||||
{task.description && <p className="text-sm text-gray-500 dark:text-gray-400 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'
|
||||
)}>
|
||||
<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 dark:bg-red-900/30 dark:text-red-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400')}>
|
||||
{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}
|
||||
{priorityInfo && <span className={clsx('px-2 py-0.5 rounded-md text-xs font-medium', priorityInfo.class)}>{priorityInfo.label}</span>}
|
||||
|
||||
{task.is_recurring && task.recurrence_type && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
|
||||
<Repeat size={12} />
|
||||
{RECURRENCE_LABELS[task.recurrence_type]}
|
||||
</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"
|
||||
>
|
||||
<button onClick={onEdit} className="p-2 text-gray-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 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="Отменить"
|
||||
>
|
||||
<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 dark:hover:bg-orange-900/20 rounded-xl transition-all" title="Отменить">
|
||||
<Undo2 size={16} />
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user