feat: add Settings page with Telegram integration, reminder time fields

This commit is contained in:
Cosmo
2026-02-06 13:16:50 +00:00
parent 199887e552
commit 208101195c
8 changed files with 609 additions and 276 deletions

View File

@@ -10,6 +10,7 @@ import VerifyEmail from "./pages/VerifyEmail"
import ResetPassword from "./pages/ResetPassword"
import ForgotPassword from "./pages/ForgotPassword"
import Stats from "./pages/Stats"
import Settings from "./pages/Settings"
function ProtectedRoute({ children }) {
const { isAuthenticated, isLoading } = useAuthStore()
@@ -114,6 +115,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<Settings />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)

14
src/api/profile.js Normal file
View File

@@ -0,0 +1,14 @@
import api from "./client"
export const profileApi = {
get: async () => {
const { data } = await api.get("/profile")
return data
},
update: async (profileData) => {
const { data } = await api.put("/profile", profileData)
return data
},
}
export default profileApi

View File

@@ -1,45 +1,46 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, ChevronDown, ChevronUp } from 'lucide-react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { habitsApi } from '../api/habits'
import clsx from 'clsx'
import { useState } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { X, ChevronDown, ChevronUp, Clock } from "lucide-react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { habitsApi } from "../api/habits"
import clsx from "clsx"
const COLORS = [
'#6366f1', '#8b5cf6', '#d946ef', '#ec4899', '#f43f5e',
'#f97316', '#eab308', '#22c55e', '#14b8a6', '#0ea5e9',
"#6366f1", "#8b5cf6", "#d946ef", "#ec4899", "#f43f5e",
"#f97316", "#eab308", "#22c55e", "#14b8a6", "#0ea5e9",
]
const ICON_CATEGORIES = [
{ name: 'Спорт', icons: ['💪', '🏃', '🚴', '🏊', '🧘', '⚽', '🏀', '🎾'] },
{ name: 'Здоровье', icons: ['💊', '💉', '🩺', '🧠', '😴', '💤', '🦷', '👁️'] },
{ name: 'Продуктивность', icons: ['📚', '📖', '✏️', '💻', '🎯', '📝', '📅', '⏰'] },
{ name: 'Дом', icons: ['🏠', '🧹', '🧺', '🍳', '🛒', '🔧', '🪴'] },
{ name: 'Финансы', icons: ['💰', '💳', '📊', '💵', '🏦'] },
{ name: 'Социальное', icons: ['👥', '💬', '📞', '👨‍👩‍👧‍👦', '❤️'] },
{ name: 'Хобби', icons: ['🎨', '🎵', '🎸', '🎮', '📷', '✈️', '🚗'] },
{ name: 'Еда/вода', icons: ['🥗', '🍎', '🥤', '☕', '🍽️', '💧'] },
{ name: 'Разное', icons: ['⭐', '🎉', '✨', '🔥', '🌟', '💎', '🎁'] },
{ name: "Спорт", icons: ["💪", "🏃", "🚴", "🏊", "🧘", "⚽", "🏀", "🎾"] },
{ name: "Здоровье", icons: ["💊", "💉", "🩺", "🧠", "😴", "💤", "🦷", "👁️"] },
{ name: "Продуктивность", icons: ["📚", "📖", "✏️", "💻", "🎯", "📝", "📅", "⏰"] },
{ name: "Дом", icons: ["🏠", "🧹", "🧺", "🍳", "🛒", "🔧", "🪴"] },
{ name: "Финансы", icons: ["💰", "💳", "📊", "💵", "🏦"] },
{ name: "Социальное", icons: ["👥", "💬", "📞", "👨👩👧👦", "❤️"] },
{ name: "Хобби", icons: ["🎨", "🎵", "🎸", "🎮", "📷", "✈️", "🚗"] },
{ name: "Еда/вода", icons: ["🥗", "🍎", "🥤", "☕", "🍽️", "💧"] },
{ name: "Разное", icons: ["⭐", "🎉", "✨", "🔥", "🌟", "💎", "🎁"] },
]
const DAYS = [
{ id: 1, short: 'Пн', full: 'Понедельник' },
{ id: 2, short: 'Вт', full: 'Вторник' },
{ id: 3, short: 'Ср', full: 'Среда' },
{ id: 4, short: 'Чт', full: 'Четверг' },
{ id: 5, short: 'Пт', full: 'Пятница' },
{ id: 6, short: 'Сб', full: 'Суббота' },
{ id: 7, short: 'Вс', full: 'Воскресенье' },
{ id: 1, short: "Пн", full: "Понедельник" },
{ id: 2, short: "Вт", full: "Вторник" },
{ id: 3, short: "Ср", full: "Среда" },
{ id: 4, short: "Чт", full: "Четверг" },
{ id: 5, short: "Пт", full: "Пятница" },
{ id: 6, short: "Сб", full: "Суббота" },
{ id: 7, short: "Вс", full: "Воскресенье" },
]
export default function CreateHabitModal({ open, onClose }) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [color, setColor] = useState(COLORS[0])
const [icon, setIcon] = useState('✨')
const [frequency, setFrequency] = useState('daily')
const [icon, setIcon] = useState("✨")
const [frequency, setFrequency] = useState("daily")
const [targetDays, setTargetDays] = useState([1, 2, 3, 4, 5, 6, 7])
const [error, setError] = useState('')
const [reminderTime, setReminderTime] = useState("")
const [error, setError] = useState("")
const [showAllIcons, setShowAllIcons] = useState(false)
const queryClient = useQueryClient()
@@ -47,23 +48,24 @@ export default function CreateHabitModal({ open, onClose }) {
const mutation = useMutation({
mutationFn: (data) => habitsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['habits'] })
queryClient.invalidateQueries({ queryKey: ['stats'] })
queryClient.invalidateQueries({ queryKey: ["habits"] })
queryClient.invalidateQueries({ queryKey: ["stats"] })
handleClose()
},
onError: (err) => {
setError(err.response?.data?.error || 'Ошибка создания')
setError(err.response?.data?.error || "Ошибка создания")
},
})
const handleClose = () => {
setName('')
setDescription('')
setName("")
setDescription("")
setColor(COLORS[0])
setIcon('✨')
setFrequency('daily')
setIcon("✨")
setFrequency("daily")
setTargetDays([1, 2, 3, 4, 5, 6, 7])
setError('')
setReminderTime("")
setError("")
setShowAllIcons(false)
onClose()
}
@@ -71,18 +73,21 @@ export default function CreateHabitModal({ open, onClose }) {
const handleSubmit = (e) => {
e.preventDefault()
if (!name.trim()) {
setError('Введи название привычки')
setError("Введи название привычки")
return
}
if (frequency === 'weekly' && targetDays.length === 0) {
setError('Выбери хотя бы один день недели')
if (frequency === "weekly" && targetDays.length === 0) {
setError("Выбери хотя бы один день недели")
return
}
const data = { name, description, color, icon, frequency }
if (frequency === 'weekly') {
if (frequency === "weekly") {
data.target_days = targetDays
}
if (reminderTime) {
data.reminder_time = reminderTime
}
mutation.mutate(data)
}
@@ -95,8 +100,7 @@ export default function CreateHabitModal({ open, onClose }) {
)
}
// Популярные иконки для быстрого выбора
const popularIcons = ['✨', '💪', '📚', '🏃', '💧', '🧘', '💤', '🎯', '✏️', '🍎']
const popularIcons = ["✨", "💪", "📚", "🏃", "💧", "🧘", "💤", "🎯", "✏️", "🍎"]
return (
<AnimatePresence>
@@ -167,24 +171,24 @@ export default function CreateHabitModal({ open, onClose }) {
<div className="flex gap-2">
<button
type="button"
onClick={() => setFrequency('daily')}
onClick={() => setFrequency("daily")}
className={clsx(
'flex-1 py-2.5 px-4 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'
"flex-1 py-2.5 px-4 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"
)}
>
Ежедневно
</button>
<button
type="button"
onClick={() => setFrequency('weekly')}
onClick={() => setFrequency("weekly")}
className={clsx(
'flex-1 py-2.5 px-4 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'
"flex-1 py-2.5 px-4 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"
)}
>
По дням недели
@@ -192,10 +196,10 @@ export default function CreateHabitModal({ open, onClose }) {
</div>
</div>
{frequency === 'weekly' && (
{frequency === "weekly" && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
>
<label className="block text-sm font-medium text-gray-700 mb-2">
@@ -208,10 +212,10 @@ export default function CreateHabitModal({ open, onClose }) {
type="button"
onClick={() => toggleDay(day.id)}
className={clsx(
'flex-1 py-2 rounded-lg font-medium text-sm transition-all',
"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-primary-500 text-white shadow-md"
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
)}
>
{day.short}
@@ -221,6 +225,24 @@ export default function CreateHabitModal({ open, onClose }) {
</motion.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">
Иконка
@@ -232,10 +254,10 @@ export default function CreateHabitModal({ open, onClose }) {
type="button"
onClick={() => setIcon(ic)}
className={clsx(
'w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all',
"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-primary-100 ring-2 ring-primary-500"
: "bg-gray-100 hover:bg-gray-200"
)}
>
{ic}
@@ -249,14 +271,14 @@ export default function CreateHabitModal({ open, onClose }) {
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 ? 'Скрыть' : 'Все иконки'}
{showAllIcons ? "Скрыть" : "Все иконки"}
</button>
<AnimatePresence>
{showAllIcons && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="mt-3 space-y-3"
>
@@ -270,10 +292,10 @@ export default function CreateHabitModal({ open, onClose }) {
type="button"
onClick={() => setIcon(ic)}
className={clsx(
'w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all',
"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-primary-100 ring-2 ring-primary-500"
: "bg-gray-100 hover:bg-gray-200"
)}
>
{ic}
@@ -298,8 +320,8 @@ export default function CreateHabitModal({ open, onClose }) {
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' : ''
"w-8 h-8 rounded-full transition-all",
color === c ? "ring-2 ring-offset-2 ring-gray-400 scale-110" : ""
)}
style={{ backgroundColor: c }}
/>
@@ -313,7 +335,7 @@ export default function CreateHabitModal({ open, onClose }) {
disabled={mutation.isPending}
className="btn btn-primary w-full"
>
{mutation.isPending ? 'Создаём...' : 'Создать привычку'}
{mutation.isPending ? "Создаём..." : "Создать привычку"}
</button>
</div>
</form>

View File

@@ -1,43 +1,44 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, ChevronDown, ChevronUp, Calendar } from 'lucide-react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { tasksApi } from '../api/tasks'
import clsx from 'clsx'
import { format, addDays } from 'date-fns'
import { useState } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { X, ChevronDown, ChevronUp, Calendar, Clock } from "lucide-react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { tasksApi } from "../api/tasks"
import clsx from "clsx"
import { format, addDays } from "date-fns"
const COLORS = [
'#6B7280', '#6366f1', '#8b5cf6', '#d946ef', '#ec4899',
'#f43f5e', '#f97316', '#eab308', '#22c55e', '#0ea5e9',
"#6B7280", "#6366f1", "#8b5cf6", "#d946ef", "#ec4899",
"#f43f5e", "#f97316", "#eab308", "#22c55e", "#0ea5e9",
]
const ICON_CATEGORIES = [
{ name: 'Продуктивность', icons: ['📋', '📝', '✅', '📌', '🎯', '💡', '📅', '⏰'] },
{ name: 'Работа', icons: ['💼', '💻', '📧', '📞', '📊', '📈', '🖥️', '⌨️'] },
{ name: 'Дом', icons: ['🏠', '🧹', '🧺', '🍳', '🛒', '🔧', '🪴', '🛋️'] },
{ name: 'Финансы', icons: ['💰', '💳', '📊', '💵', '🏦', '🧾'] },
{ name: 'Здоровье', icons: ['💊', '🏃', '🧘', '💪', '🩺', '🦷'] },
{ name: 'Разное', icons: ['⭐', '🎁', '📦', '✈️', '🚗', '📷', '🎉'] },
{ name: "Продуктивность", icons: ["📋", "📝", "✅", "📌", "🎯", "💡", "📅", "⏰"] },
{ name: "Работа", icons: ["💼", "💻", "📧", "📞", "📊", "📈", "🖥️", "⌨️"] },
{ name: "Дом", icons: ["🏠", "🧹", "🧺", "🍳", "🛒", "🔧", "🪴", "🛋️"] },
{ name: "Финансы", icons: ["💰", "💳", "📊", "💵", "🏦", "🧾"] },
{ name: "Здоровье", icons: ["💊", "🏃", "🧘", "💪", "🩺", "🦷"] },
{ name: "Разное", icons: ["⭐", "🎁", "📦", "✈️", "🚗", "📷", "🎉"] },
]
const PRIORITIES = [
{ value: 0, label: 'Без приоритета', color: 'bg-gray-100 text-gray-600' },
{ value: 1, label: 'Низкий', color: 'bg-blue-100 text-blue-700' },
{ value: 2, label: 'Средний', color: 'bg-yellow-100 text-yellow-700' },
{ value: 3, label: 'Высокий', color: 'bg-red-100 text-red-700' },
{ value: 0, label: "Без приоритета", color: "bg-gray-100 text-gray-600" },
{ value: 1, label: "Низкий", color: "bg-blue-100 text-blue-700" },
{ value: 2, label: "Средний", color: "bg-yellow-100 text-yellow-700" },
{ value: 3, label: "Высокий", color: "bg-red-100 text-red-700" },
]
export default function CreateTaskModal({ open, onClose, defaultDueDate = null }) {
const today = format(new Date(), 'yyyy-MM-dd')
const tomorrow = format(addDays(new Date(), 1), 'yyyy-MM-dd')
const today = format(new Date(), "yyyy-MM-dd")
const tomorrow = format(addDays(new Date(), 1), "yyyy-MM-dd")
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const [color, setColor] = useState(COLORS[0])
const [icon, setIcon] = useState('📋')
const [icon, setIcon] = useState("📋")
const [dueDate, setDueDate] = useState(defaultDueDate || today)
const [priority, setPriority] = useState(0)
const [error, setError] = useState('')
const [reminderTime, setReminderTime] = useState("")
const [error, setError] = useState("")
const [showAllIcons, setShowAllIcons] = useState(false)
const queryClient = useQueryClient()
@@ -45,23 +46,24 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
const mutation = useMutation({
mutationFn: (data) => tasksApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
queryClient.invalidateQueries({ queryKey: ['tasks-today'] })
queryClient.invalidateQueries({ queryKey: ["tasks"] })
queryClient.invalidateQueries({ queryKey: ["tasks-today"] })
handleClose()
},
onError: (err) => {
setError(err.response?.data?.error || 'Ошибка создания')
setError(err.response?.data?.error || "Ошибка создания")
},
})
const handleClose = () => {
setTitle('')
setDescription('')
setTitle("")
setDescription("")
setColor(COLORS[0])
setIcon('📋')
setIcon("📋")
setDueDate(defaultDueDate || today)
setPriority(0)
setError('')
setReminderTime("")
setError("")
setShowAllIcons(false)
onClose()
}
@@ -69,7 +71,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
const handleSubmit = (e) => {
e.preventDefault()
if (!title.trim()) {
setError('Введи название задачи')
setError("Введи название задачи")
return
}
@@ -80,10 +82,11 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
icon,
due_date: dueDate || null,
priority,
reminder_time: reminderTime || null,
})
}
const popularIcons = ['📋', '📝', '✅', '🎯', '💼', '🏠', '💰', '📞']
const popularIcons = ["📋", "📝", "✅", "🎯", "💼", "🏠", "💰", "📞"]
return (
<AnimatePresence>
@@ -155,10 +158,10 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
type="button"
onClick={() => setDueDate(today)}
className={clsx(
'px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
"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-primary-500 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
)}
>
Сегодня
@@ -167,22 +170,22 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
type="button"
onClick={() => setDueDate(tomorrow)}
className={clsx(
'px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
"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-primary-500 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
)}
>
Завтра
</button>
<button
type="button"
onClick={() => setDueDate('')}
onClick={() => setDueDate("")}
className={clsx(
'px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
"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-primary-500 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
)}
>
Без срока
@@ -199,6 +202,24 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
</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">
Приоритет
@@ -210,10 +231,10 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
type="button"
onClick={() => setPriority(p.value)}
className={clsx(
'px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
"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.color + " ring-2 ring-offset-1 ring-gray-400"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
)}
>
{p.label}
@@ -233,10 +254,10 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
type="button"
onClick={() => setIcon(ic)}
className={clsx(
'w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all',
"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-primary-100 ring-2 ring-primary-500"
: "bg-gray-100 hover:bg-gray-200"
)}
>
{ic}
@@ -250,14 +271,14 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
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 ? 'Скрыть' : 'Все иконки'}
{showAllIcons ? "Скрыть" : "Все иконки"}
</button>
<AnimatePresence>
{showAllIcons && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="mt-3 space-y-3"
>
@@ -271,10 +292,10 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
type="button"
onClick={() => setIcon(ic)}
className={clsx(
'w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all',
"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-primary-100 ring-2 ring-primary-500"
: "bg-gray-100 hover:bg-gray-200"
)}
>
{ic}
@@ -299,8 +320,8 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
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' : ''
"w-8 h-8 rounded-full transition-all",
color === c ? "ring-2 ring-offset-2 ring-gray-400 scale-110" : ""
)}
style={{ backgroundColor: c }}
/>
@@ -314,7 +335,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
disabled={mutation.isPending}
className="btn btn-primary w-full"
>
{mutation.isPending ? 'Создаём...' : 'Создать задачу'}
{mutation.isPending ? "Создаём..." : "Создать задачу"}
</button>
</div>
</form>

View File

@@ -1,45 +1,46 @@
import { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Trash2, ChevronDown, ChevronUp } from 'lucide-react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { habitsApi } from '../api/habits'
import clsx from 'clsx'
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 { habitsApi } from "../api/habits"
import clsx from "clsx"
const COLORS = [
'#6366f1', '#8b5cf6', '#d946ef', '#ec4899', '#f43f5e',
'#f97316', '#eab308', '#22c55e', '#14b8a6', '#0ea5e9',
"#6366f1", "#8b5cf6", "#d946ef", "#ec4899", "#f43f5e",
"#f97316", "#eab308", "#22c55e", "#14b8a6", "#0ea5e9",
]
const ICON_CATEGORIES = [
{ name: 'Спорт', icons: ['💪', '🏃', '🚴', '🏊', '🧘', '⚽', '🏀', '🎾'] },
{ name: 'Здоровье', icons: ['💊', '💉', '🩺', '🧠', '😴', '💤', '🦷', '👁️'] },
{ name: 'Продуктивность', icons: ['📚', '📖', '✏️', '💻', '🎯', '📝', '📅', '⏰'] },
{ name: 'Дом', icons: ['🏠', '🧹', '🧺', '🍳', '🛒', '🔧', '🪴'] },
{ name: 'Финансы', icons: ['💰', '💳', '📊', '💵', '🏦'] },
{ name: 'Социальное', icons: ['👥', '💬', '📞', '👨‍👩‍👧‍👦', '❤️'] },
{ name: 'Хобби', icons: ['🎨', '🎵', '🎸', '🎮', '📷', '✈️', '🚗'] },
{ name: 'Еда/вода', icons: ['🥗', '🍎', '🥤', '☕', '🍽️', '💧'] },
{ name: 'Разное', icons: ['⭐', '🎉', '✨', '🔥', '🌟', '💎', '🎁'] },
{ name: "Спорт", icons: ["💪", "🏃", "🚴", "🏊", "🧘", "⚽", "🏀", "🎾"] },
{ name: "Здоровье", icons: ["💊", "💉", "🩺", "🧠", "😴", "💤", "🦷", "👁️"] },
{ name: "Продуктивность", icons: ["📚", "📖", "✏️", "💻", "🎯", "📝", "📅", "⏰"] },
{ name: "Дом", icons: ["🏠", "🧹", "🧺", "🍳", "🛒", "🔧", "🪴"] },
{ name: "Финансы", icons: ["💰", "💳", "📊", "💵", "🏦"] },
{ name: "Социальное", icons: ["👥", "💬", "📞", "👨👩👧👦", "❤️"] },
{ name: "Хобби", icons: ["🎨", "🎵", "🎸", "🎮", "📷", "✈️", "🚗"] },
{ name: "Еда/вода", icons: ["🥗", "🍎", "🥤", "☕", "🍽️", "💧"] },
{ name: "Разное", icons: ["⭐", "🎉", "✨", "🔥", "🌟", "💎", "🎁"] },
]
const DAYS = [
{ id: 1, short: 'Пн' },
{ id: 2, short: 'Вт' },
{ id: 3, short: 'Ср' },
{ id: 4, short: 'Чт' },
{ id: 5, short: 'Пт' },
{ id: 6, short: 'Сб' },
{ id: 7, short: 'Вс' },
{ id: 1, short: "Пн" },
{ id: 2, short: "Вт" },
{ id: 3, short: "Ср" },
{ id: 4, short: "Чт" },
{ id: 5, short: "Пт" },
{ id: 6, short: "Сб" },
{ id: 7, short: "Вс" },
]
export default function EditHabitModal({ open, onClose, habit }) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [color, setColor] = useState(COLORS[0])
const [icon, setIcon] = useState('✨')
const [frequency, setFrequency] = useState('daily')
const [icon, setIcon] = useState("✨")
const [frequency, setFrequency] = useState("daily")
const [targetDays, setTargetDays] = useState([1, 2, 3, 4, 5, 6, 7])
const [error, setError] = useState('')
const [reminderTime, setReminderTime] = useState("")
const [error, setError] = useState("")
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showAllIcons, setShowAllIcons] = useState(false)
@@ -47,13 +48,14 @@ export default function EditHabitModal({ open, onClose, habit }) {
useEffect(() => {
if (habit && open) {
setName(habit.name || '')
setDescription(habit.description || '')
setName(habit.name || "")
setDescription(habit.description || "")
setColor(habit.color || COLORS[0])
setIcon(habit.icon || '✨')
setFrequency(habit.frequency || 'daily')
setIcon(habit.icon || "✨")
setFrequency(habit.frequency || "daily")
setTargetDays(habit.target_days || [1, 2, 3, 4, 5, 6, 7])
setError('')
setReminderTime(habit.reminder_time || "")
setError("")
setShowDeleteConfirm(false)
setShowAllIcons(false)
}
@@ -62,29 +64,29 @@ export default function EditHabitModal({ open, onClose, habit }) {
const updateMutation = useMutation({
mutationFn: (data) => habitsApi.update(habit.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['habits'] })
queryClient.invalidateQueries({ queryKey: ['stats'] })
queryClient.invalidateQueries({ queryKey: ["habits"] })
queryClient.invalidateQueries({ queryKey: ["stats"] })
onClose()
},
onError: (err) => {
setError(err.response?.data?.error || 'Ошибка сохранения')
setError(err.response?.data?.error || "Ошибка сохранения")
},
})
const deleteMutation = useMutation({
mutationFn: () => habitsApi.delete(habit.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['habits'] })
queryClient.invalidateQueries({ queryKey: ['stats'] })
queryClient.invalidateQueries({ queryKey: ["habits"] })
queryClient.invalidateQueries({ queryKey: ["stats"] })
onClose()
},
onError: (err) => {
setError(err.response?.data?.error || 'Ошибка удаления')
setError(err.response?.data?.error || "Ошибка удаления")
},
})
const handleClose = () => {
setError('')
setError("")
setShowDeleteConfirm(false)
setShowAllIcons(false)
onClose()
@@ -93,18 +95,19 @@ export default function EditHabitModal({ open, onClose, habit }) {
const handleSubmit = (e) => {
e.preventDefault()
if (!name.trim()) {
setError('Введи название привычки')
setError("Введи название привычки")
return
}
if (frequency === 'weekly' && targetDays.length === 0) {
setError('Выбери хотя бы один день недели')
if (frequency === "weekly" && targetDays.length === 0) {
setError("Выбери хотя бы один день недели")
return
}
const data = { name, description, color, icon, frequency }
if (frequency === 'weekly') {
if (frequency === "weekly") {
data.target_days = targetDays
}
data.reminder_time = reminderTime || null
updateMutation.mutate(data)
}
@@ -121,7 +124,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
)
}
const popularIcons = ['✨', '💪', '📚', '🏃', '💧', '🧘', '💤', '🎯', '✏️', '🍎']
const popularIcons = ["✨", "💪", "📚", "🏃", "💧", "🧘", "💤", "🎯", "✏️", "🍎"]
if (!habit) return null
@@ -174,7 +177,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
disabled={deleteMutation.isPending}
className="flex-1 btn bg-red-500 text-white hover:bg-red-600"
>
{deleteMutation.isPending ? 'Удаляем...' : 'Удалить'}
{deleteMutation.isPending ? "Удаляем..." : "Удалить"}
</button>
</div>
</div>
@@ -219,24 +222,24 @@ export default function EditHabitModal({ open, onClose, habit }) {
<div className="flex gap-2">
<button
type="button"
onClick={() => setFrequency('daily')}
onClick={() => setFrequency("daily")}
className={clsx(
'flex-1 py-2.5 px-4 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'
"flex-1 py-2.5 px-4 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"
)}
>
Ежедневно
</button>
<button
type="button"
onClick={() => setFrequency('weekly')}
onClick={() => setFrequency("weekly")}
className={clsx(
'flex-1 py-2.5 px-4 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'
"flex-1 py-2.5 px-4 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"
)}
>
По дням недели
@@ -244,10 +247,10 @@ export default function EditHabitModal({ open, onClose, habit }) {
</div>
</div>
{frequency === 'weekly' && (
{frequency === "weekly" && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
>
<label className="block text-sm font-medium text-gray-700 mb-2">
@@ -260,10 +263,10 @@ export default function EditHabitModal({ open, onClose, habit }) {
type="button"
onClick={() => toggleDay(day.id)}
className={clsx(
'flex-1 py-2 rounded-lg font-medium text-sm transition-all',
"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-primary-500 text-white shadow-md"
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
)}
>
{day.short}
@@ -273,6 +276,24 @@ export default function EditHabitModal({ open, onClose, habit }) {
</motion.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">
Иконка
@@ -284,10 +305,10 @@ export default function EditHabitModal({ open, onClose, habit }) {
type="button"
onClick={() => setIcon(ic)}
className={clsx(
'w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all',
"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-primary-100 ring-2 ring-primary-500"
: "bg-gray-100 hover:bg-gray-200"
)}
>
{ic}
@@ -301,14 +322,14 @@ export default function EditHabitModal({ open, onClose, habit }) {
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 ? 'Скрыть' : 'Все иконки'}
{showAllIcons ? "Скрыть" : "Все иконки"}
</button>
<AnimatePresence>
{showAllIcons && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="mt-3 space-y-3"
>
@@ -322,10 +343,10 @@ export default function EditHabitModal({ open, onClose, habit }) {
type="button"
onClick={() => setIcon(ic)}
className={clsx(
'w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all',
"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-primary-100 ring-2 ring-primary-500"
: "bg-gray-100 hover:bg-gray-200"
)}
>
{ic}
@@ -350,8 +371,8 @@ export default function EditHabitModal({ open, onClose, habit }) {
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' : ''
"w-8 h-8 rounded-full transition-all",
color === c ? "ring-2 ring-offset-2 ring-gray-400 scale-110" : ""
)}
style={{ backgroundColor: c }}
/>
@@ -365,7 +386,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
disabled={updateMutation.isPending}
className="btn btn-primary w-full"
>
{updateMutation.isPending ? 'Сохраняем...' : 'Сохранить изменения'}
{updateMutation.isPending ? "Сохраняем..." : "Сохранить изменения"}
</button>
<button

View File

@@ -1,43 +1,44 @@
import { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Trash2, ChevronDown, ChevronUp, Calendar } from 'lucide-react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { tasksApi } from '../api/tasks'
import clsx from 'clsx'
import { format, addDays } from 'date-fns'
import { useState, useEffect } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { X, Trash2, ChevronDown, ChevronUp, Calendar, Clock } from "lucide-react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { tasksApi } from "../api/tasks"
import clsx from "clsx"
import { format, addDays } from "date-fns"
const COLORS = [
'#6B7280', '#6366f1', '#8b5cf6', '#d946ef', '#ec4899',
'#f43f5e', '#f97316', '#eab308', '#22c55e', '#0ea5e9',
"#6B7280", "#6366f1", "#8b5cf6", "#d946ef", "#ec4899",
"#f43f5e", "#f97316", "#eab308", "#22c55e", "#0ea5e9",
]
const ICON_CATEGORIES = [
{ name: 'Продуктивность', icons: ['📋', '📝', '✅', '📌', '🎯', '💡', '📅', '⏰'] },
{ name: 'Работа', icons: ['💼', '💻', '📧', '📞', '📊', '📈', '🖥️', '⌨️'] },
{ name: 'Дом', icons: ['🏠', '🧹', '🧺', '🍳', '🛒', '🔧', '🪴', '🛋️'] },
{ name: 'Финансы', icons: ['💰', '💳', '📊', '💵', '🏦', '🧾'] },
{ name: 'Здоровье', icons: ['💊', '🏃', '🧘', '💪', '🩺', '🦷'] },
{ name: 'Разное', icons: ['⭐', '🎁', '📦', '✈️', '🚗', '📷', '🎉'] },
{ name: "Продуктивность", icons: ["📋", "📝", "✅", "📌", "🎯", "💡", "📅", "⏰"] },
{ name: "Работа", icons: ["💼", "💻", "📧", "📞", "📊", "📈", "🖥️", "⌨️"] },
{ name: "Дом", icons: ["🏠", "🧹", "🧺", "🍳", "🛒", "🔧", "🪴", "🛋️"] },
{ name: "Финансы", icons: ["💰", "💳", "📊", "💵", "🏦", "🧾"] },
{ name: "Здоровье", icons: ["💊", "🏃", "🧘", "💪", "🩺", "🦷"] },
{ name: "Разное", icons: ["⭐", "🎁", "📦", "✈️", "🚗", "📷", "🎉"] },
]
const PRIORITIES = [
{ value: 0, label: 'Без приоритета', color: 'bg-gray-100 text-gray-600' },
{ value: 1, label: 'Низкий', color: 'bg-blue-100 text-blue-700' },
{ value: 2, label: 'Средний', color: 'bg-yellow-100 text-yellow-700' },
{ value: 3, label: 'Высокий', color: 'bg-red-100 text-red-700' },
{ value: 0, label: "Без приоритета", color: "bg-gray-100 text-gray-600" },
{ value: 1, label: "Низкий", color: "bg-blue-100 text-blue-700" },
{ value: 2, label: "Средний", color: "bg-yellow-100 text-yellow-700" },
{ value: 3, label: "Высокий", color: "bg-red-100 text-red-700" },
]
export default function EditTaskModal({ open, onClose, task }) {
const today = format(new Date(), 'yyyy-MM-dd')
const tomorrow = format(addDays(new Date(), 1), 'yyyy-MM-dd')
const today = format(new Date(), "yyyy-MM-dd")
const tomorrow = format(addDays(new Date(), 1), "yyyy-MM-dd")
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const [color, setColor] = useState(COLORS[0])
const [icon, setIcon] = useState('📋')
const [dueDate, setDueDate] = useState('')
const [icon, setIcon] = useState("📋")
const [dueDate, setDueDate] = useState("")
const [priority, setPriority] = useState(0)
const [error, setError] = useState('')
const [reminderTime, setReminderTime] = useState("")
const [error, setError] = useState("")
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showAllIcons, setShowAllIcons] = useState(false)
@@ -45,13 +46,14 @@ export default function EditTaskModal({ open, onClose, task }) {
useEffect(() => {
if (task && open) {
setTitle(task.title || '')
setDescription(task.description || '')
setTitle(task.title || "")
setDescription(task.description || "")
setColor(task.color || COLORS[0])
setIcon(task.icon || '📋')
setDueDate(task.due_date || '')
setIcon(task.icon || "📋")
setDueDate(task.due_date || "")
setPriority(task.priority || 0)
setError('')
setReminderTime(task.reminder_time || "")
setError("")
setShowDeleteConfirm(false)
setShowAllIcons(false)
}
@@ -60,29 +62,29 @@ export default function EditTaskModal({ open, onClose, task }) {
const updateMutation = useMutation({
mutationFn: (data) => tasksApi.update(task.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
queryClient.invalidateQueries({ queryKey: ['tasks-today'] })
queryClient.invalidateQueries({ queryKey: ["tasks"] })
queryClient.invalidateQueries({ queryKey: ["tasks-today"] })
onClose()
},
onError: (err) => {
setError(err.response?.data?.error || 'Ошибка сохранения')
setError(err.response?.data?.error || "Ошибка сохранения")
},
})
const deleteMutation = useMutation({
mutationFn: () => tasksApi.delete(task.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
queryClient.invalidateQueries({ queryKey: ['tasks-today'] })
queryClient.invalidateQueries({ queryKey: ["tasks"] })
queryClient.invalidateQueries({ queryKey: ["tasks-today"] })
onClose()
},
onError: (err) => {
setError(err.response?.data?.error || 'Ошибка удаления')
setError(err.response?.data?.error || "Ошибка удаления")
},
})
const handleClose = () => {
setError('')
setError("")
setShowDeleteConfirm(false)
setShowAllIcons(false)
onClose()
@@ -91,7 +93,7 @@ export default function EditTaskModal({ open, onClose, task }) {
const handleSubmit = (e) => {
e.preventDefault()
if (!title.trim()) {
setError('Введи название задачи')
setError("Введи название задачи")
return
}
@@ -102,6 +104,7 @@ export default function EditTaskModal({ open, onClose, task }) {
icon,
due_date: dueDate || null,
priority,
reminder_time: reminderTime || null,
})
}
@@ -109,7 +112,7 @@ export default function EditTaskModal({ open, onClose, task }) {
deleteMutation.mutate()
}
const popularIcons = ['📋', '📝', '✅', '🎯', '💼', '🏠', '💰', '📞']
const popularIcons = ["📋", "📝", "✅", "🎯", "💼", "🏠", "💰", "📞"]
if (!task) return null
@@ -162,7 +165,7 @@ export default function EditTaskModal({ open, onClose, task }) {
disabled={deleteMutation.isPending}
className="flex-1 btn bg-red-500 text-white hover:bg-red-600"
>
{deleteMutation.isPending ? 'Удаляем...' : 'Удалить'}
{deleteMutation.isPending ? "Удаляем..." : "Удалить"}
</button>
</div>
</div>
@@ -208,10 +211,10 @@ export default function EditTaskModal({ open, onClose, task }) {
type="button"
onClick={() => setDueDate(today)}
className={clsx(
'px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
"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-primary-500 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
)}
>
Сегодня
@@ -220,22 +223,22 @@ export default function EditTaskModal({ open, onClose, task }) {
type="button"
onClick={() => setDueDate(tomorrow)}
className={clsx(
'px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
"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-primary-500 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
)}
>
Завтра
</button>
<button
type="button"
onClick={() => setDueDate('')}
onClick={() => setDueDate("")}
className={clsx(
'px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
"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-primary-500 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
)}
>
Без срока
@@ -252,6 +255,24 @@ export default function EditTaskModal({ open, onClose, task }) {
</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">
Приоритет
@@ -263,10 +284,10 @@ export default function EditTaskModal({ open, onClose, task }) {
type="button"
onClick={() => setPriority(p.value)}
className={clsx(
'px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
"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.color + " ring-2 ring-offset-1 ring-gray-400"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
)}
>
{p.label}
@@ -286,10 +307,10 @@ export default function EditTaskModal({ open, onClose, task }) {
type="button"
onClick={() => setIcon(ic)}
className={clsx(
'w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all',
"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-primary-100 ring-2 ring-primary-500"
: "bg-gray-100 hover:bg-gray-200"
)}
>
{ic}
@@ -303,14 +324,14 @@ export default function EditTaskModal({ open, onClose, task }) {
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 ? 'Скрыть' : 'Все иконки'}
{showAllIcons ? "Скрыть" : "Все иконки"}
</button>
<AnimatePresence>
{showAllIcons && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="mt-3 space-y-3"
>
@@ -324,10 +345,10 @@ export default function EditTaskModal({ open, onClose, task }) {
type="button"
onClick={() => setIcon(ic)}
className={clsx(
'w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all',
"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-primary-100 ring-2 ring-primary-500"
: "bg-gray-100 hover:bg-gray-200"
)}
>
{ic}
@@ -352,8 +373,8 @@ export default function EditTaskModal({ open, onClose, task }) {
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' : ''
"w-8 h-8 rounded-full transition-all",
color === c ? "ring-2 ring-offset-2 ring-gray-400 scale-110" : ""
)}
style={{ backgroundColor: c }}
/>
@@ -367,7 +388,7 @@ export default function EditTaskModal({ open, onClose, task }) {
disabled={updateMutation.isPending}
className="btn btn-primary w-full"
>
{updateMutation.isPending ? 'Сохраняем...' : 'Сохранить изменения'}
{updateMutation.isPending ? "Сохраняем..." : "Сохранить изменения"}
</button>
<button

View File

@@ -1,13 +1,14 @@
import { NavLink } from 'react-router-dom'
import { Home, ListChecks, CheckSquare, BarChart3 } from 'lucide-react'
import clsx from 'clsx'
import { NavLink } from "react-router-dom"
import { Home, ListChecks, CheckSquare, BarChart3, Settings } from "lucide-react"
import clsx from "clsx"
export default function Navigation() {
const navItems = [
{ to: '/', icon: Home, label: 'Сегодня' },
{ to: '/habits', icon: ListChecks, label: 'Привычки' },
{ to: '/tasks', icon: CheckSquare, label: 'Задачи' },
{ to: '/stats', icon: BarChart3, label: 'Статистика' },
{ to: "/", icon: Home, label: "Сегодня" },
{ to: "/habits", icon: ListChecks, label: "Привычки" },
{ to: "/tasks", icon: CheckSquare, label: "Задачи" },
{ to: "/stats", icon: BarChart3, label: "Статистика" },
{ to: "/settings", icon: Settings, label: "Настройки" },
]
return (
@@ -20,14 +21,14 @@ export default function Navigation() {
to={to}
className={({ isActive }) =>
clsx(
'flex flex-col items-center gap-1 px-3 py-2 rounded-xl transition-all',
"flex flex-col items-center gap-1 px-2 py-2 rounded-xl transition-all",
isActive
? 'text-primary-600 bg-primary-50'
: 'text-gray-400 hover:text-gray-600'
? "text-primary-600 bg-primary-50"
: "text-gray-400 hover:text-gray-600"
)
}
>
<Icon size={22} />
<Icon size={20} />
<span className="text-xs font-medium">{label}</span>
</NavLink>
))}

224
src/pages/Settings.jsx Normal file
View File

@@ -0,0 +1,224 @@
import { useState, useEffect } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { ArrowLeft, Bell, MessageCircle, Globe, Save, Copy, Check } from "lucide-react"
import { Link } from "react-router-dom"
import { profileApi } from "../api/profile"
import Navigation from "../components/Navigation"
const TIMEZONES = [
{ value: "Europe/Moscow", label: "Москва (UTC+3)" },
{ value: "Europe/Kaliningrad", label: "Калининград (UTC+2)" },
{ value: "Europe/Samara", label: "Самара (UTC+4)" },
{ value: "Asia/Yekaterinburg", label: "Екатеринбург (UTC+5)" },
{ value: "Asia/Omsk", label: "Омск (UTC+6)" },
{ value: "Asia/Krasnoyarsk", label: "Красноярск (UTC+7)" },
{ value: "Asia/Irkutsk", label: "Иркутск (UTC+8)" },
{ value: "Asia/Yakutsk", label: "Якутск (UTC+9)" },
{ value: "Asia/Vladivostok", label: "Владивосток (UTC+10)" },
{ value: "Asia/Magadan", label: "Магадан (UTC+11)" },
{ value: "Asia/Kamchatka", label: "Камчатка (UTC+12)" },
{ value: "Asia/Tokyo", label: "Токио (UTC+9)" },
{ value: "Europe/London", label: "Лондон (UTC+0)" },
{ value: "Europe/Berlin", label: "Берлин (UTC+1)" },
{ value: "America/New_York", label: "Нью-Йорк (UTC-5)" },
{ value: "America/Los_Angeles", label: "Лос-Анджелес (UTC-8)" },
]
export default function Settings() {
const queryClient = useQueryClient()
const [copied, setCopied] = useState(false)
const [chatId, setChatId] = useState("")
const [notificationsEnabled, setNotificationsEnabled] = useState(true)
const [timezone, setTimezone] = useState("Europe/Moscow")
const [hasChanges, setHasChanges] = useState(false)
const { data: profile, isLoading } = useQuery({
queryKey: ["profile"],
queryFn: profileApi.get,
})
useEffect(() => {
if (profile) {
setChatId(profile.telegram_chat_id?.toString() || "")
setNotificationsEnabled(profile.notifications_enabled ?? true)
setTimezone(profile.timezone || "Europe/Moscow")
}
}, [profile])
useEffect(() => {
if (profile) {
const changed =
(chatId !== (profile.telegram_chat_id?.toString() || "")) ||
notificationsEnabled !== (profile.notifications_enabled ?? true) ||
timezone !== (profile.timezone || "Europe/Moscow")
setHasChanges(changed)
}
}, [chatId, notificationsEnabled, timezone, profile])
const mutation = useMutation({
mutationFn: profileApi.update,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["profile"] })
setHasChanges(false)
},
})
const handleSave = () => {
const data = {
notifications_enabled: notificationsEnabled,
timezone: timezone,
}
if (chatId) {
data.telegram_chat_id = parseInt(chatId, 10)
}
mutation.mutate(data)
}
const copyInstruction = () => {
navigator.clipboard.writeText("@PulseNotifyBot")
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
if (isLoading) {
return (
<div className="min-h-screen bg-surface-50 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">
{/* Header */}
<header className="bg-white/80 backdrop-blur-xl sticky top-0 z-40 border-b border-gray-100">
<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">
<ArrowLeft size={20} />
</Link>
<h1 className="text-xl font-bold">Настройки</h1>
</div>
</header>
<main className="max-w-lg mx-auto px-4 py-6 space-y-6">
{/* Telegram Section */}
<section className="bg-white rounded-2xl p-4 shadow-sm">
<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>
<div>
<h2 className="font-semibold">Telegram</h2>
<p className="text-sm text-gray-500">Получай уведомления в 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
</p>
<button
onClick={copyInstruction}
className="flex items-center gap-2 text-sm font-medium text-blue-600 hover:text-blue-700"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
{copied ? "Скопировано!" : "@PulseNotifyBot"}
</button>
<p className="text-sm text-blue-800 mt-2">
2. Скопируй Chat ID из ответа бота и вставь ниже
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Chat ID
</label>
<input
type="text"
value={chatId}
onChange={(e) => setChatId(e.target.value.replace(/\D/g, ""))}
placeholder="Например: 123456789"
className="input"
/>
</div>
</div>
</section>
{/* Notifications Section */}
<section className="bg-white rounded-2xl p-4 shadow-sm">
<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>
<div>
<h2 className="font-semibold">Уведомления</h2>
<p className="text-sm text-gray-500">Настрой push-уведомления</p>
</div>
</div>
<label className="flex items-center justify-between p-3 bg-gray-50 rounded-xl cursor-pointer">
<span className="text-sm font-medium">Включить уведомления</span>
<div className="relative">
<input
type="checkbox"
checked={notificationsEnabled}
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="absolute left-1 top-1 w-4 h-4 bg-white rounded-full peer-checked:translate-x-5 transition-transform"></div>
</div>
</label>
</section>
{/* Timezone Section */}
<section className="bg-white rounded-2xl p-4 shadow-sm">
<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>
<div>
<h2 className="font-semibold">Часовой пояс</h2>
<p className="text-sm text-gray-500">Для корректных напоминаний</p>
</div>
</div>
<select
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
className="input"
>
{TIMEZONES.map((tz) => (
<option key={tz.value} value={tz.value}>
{tz.label}
</option>
))}
</select>
</section>
{/* Save Button */}
{hasChanges && (
<button
onClick={handleSave}
disabled={mutation.isPending}
className="btn btn-primary w-full flex items-center justify-center gap-2"
>
<Save size={18} />
{mutation.isPending ? "Сохраняем..." : "Сохранить изменения"}
</button>
)}
{mutation.isSuccess && !hasChanges && (
<div className="p-3 rounded-xl bg-green-50 text-green-700 text-sm text-center">
Настройки сохранены
</div>
)}
</main>
<Navigation />
</div>
)
}