Initial commit: Pulse web app
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
12
Dockerfile
Normal file
12
Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
networks:
|
||||||
|
proxy:
|
||||||
|
external: true
|
||||||
|
name: services_proxy
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
container_name: pulse-web
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
19
index.html
Normal file
19
index.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<meta name="theme-color" content="#0ea5e9" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<title>Pulse</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
22
nginx.conf
Normal file
22
nginx.conf
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Disable caching for favicon
|
||||||
|
location = /favicon.svg {
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
add_header Pragma "no-cache";
|
||||||
|
add_header Expires "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /assets {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "habits-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.22.0",
|
||||||
|
"@tanstack/react-query": "^5.17.0",
|
||||||
|
"axios": "^1.6.5",
|
||||||
|
"zustand": "^4.5.0",
|
||||||
|
"date-fns": "^3.3.1",
|
||||||
|
"lucide-react": "^0.312.0",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"framer-motion": "^11.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.48",
|
||||||
|
"@types/react-dom": "^18.2.18",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.17",
|
||||||
|
"postcss": "^8.4.33",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"vite": "^5.0.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
4
public/favicon.svg
Normal file
4
public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<circle cx="16" cy="16" r="16" fill="#115E59"/>
|
||||||
|
<path d="M18 4L8 18h6l-2 10 10-14h-6l2-10z" fill="#F59E0B" stroke="#FBBF24" stroke-width="0.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 217 B |
120
src/App.jsx
Normal file
120
src/App.jsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useEffect } from "react"
|
||||||
|
import { Routes, Route, Navigate } from "react-router-dom"
|
||||||
|
import { useAuthStore } from "./store/auth"
|
||||||
|
import Login from "./pages/Login"
|
||||||
|
import Register from "./pages/Register"
|
||||||
|
import Home from "./pages/Home"
|
||||||
|
import Habits from "./pages/Habits"
|
||||||
|
import Tasks from "./pages/Tasks"
|
||||||
|
import VerifyEmail from "./pages/VerifyEmail"
|
||||||
|
import ResetPassword from "./pages/ResetPassword"
|
||||||
|
import ForgotPassword from "./pages/ForgotPassword"
|
||||||
|
import Stats from "./pages/Stats"
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }) {
|
||||||
|
const { isAuthenticated, isLoading } = useAuthStore()
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-surface-50">
|
||||||
|
<div className="w-10 h-10 border-4 border-primary-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
function PublicRoute({ children }) {
|
||||||
|
const { isAuthenticated, isLoading } = useAuthStore()
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-surface-50">
|
||||||
|
<div className="w-10 h-10 border-4 border-primary-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return <Navigate to="/" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const initialize = useAuthStore((s) => s.initialize)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initialize()
|
||||||
|
}, [initialize])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/login"
|
||||||
|
element={
|
||||||
|
<PublicRoute>
|
||||||
|
<Login />
|
||||||
|
</PublicRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/register"
|
||||||
|
element={
|
||||||
|
<PublicRoute>
|
||||||
|
<Register />
|
||||||
|
</PublicRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/forgot-password"
|
||||||
|
element={
|
||||||
|
<PublicRoute>
|
||||||
|
<ForgotPassword />
|
||||||
|
</PublicRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Home />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/habits"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Habits />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/tasks"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Tasks />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/stats"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Stats />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
src/api/client.js
Normal file
52
src/api/client.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || 'https://api.digital-home.site'
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: API_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config
|
||||||
|
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true
|
||||||
|
|
||||||
|
const refreshToken = localStorage.getItem('refresh_token')
|
||||||
|
if (refreshToken) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post(`${API_URL}/auth/refresh`, {
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
localStorage.setItem('access_token', data.access_token)
|
||||||
|
localStorage.setItem('refresh_token', data.refresh_token)
|
||||||
|
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${data.access_token}`
|
||||||
|
return api(originalRequest)
|
||||||
|
} catch (refreshError) {
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
localStorage.removeItem('refresh_token')
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default api
|
||||||
16
src/api/habits.js
Normal file
16
src/api/habits.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import api from './client'
|
||||||
|
|
||||||
|
export const habitsApi = {
|
||||||
|
list: () => api.get('/habits').then(r => r.data),
|
||||||
|
get: (id) => api.get(`/habits/${id}`).then(r => r.data),
|
||||||
|
create: (data) => api.post('/habits', data).then(r => r.data),
|
||||||
|
update: (id, data) => api.put(`/habits/${id}`, data).then(r => r.data),
|
||||||
|
delete: (id) => api.delete(`/habits/${id}`),
|
||||||
|
|
||||||
|
log: (id, data = {}) => api.post(`/habits/${id}/log`, data).then(r => r.data),
|
||||||
|
getLogs: (id, days = 30) => api.get(`/habits/${id}/logs?days=${days}`).then(r => r.data),
|
||||||
|
deleteLog: (habitId, logId) => api.delete(`/habits/${habitId}/logs/${logId}`),
|
||||||
|
|
||||||
|
getStats: () => api.get('/habits/stats').then(r => r.data),
|
||||||
|
getHabitStats: (id) => api.get(`/habits/${id}/stats`).then(r => r.data),
|
||||||
|
}
|
||||||
46
src/api/tasks.js
Normal file
46
src/api/tasks.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import client from './client'
|
||||||
|
|
||||||
|
export const tasksApi = {
|
||||||
|
list: async (completed = null) => {
|
||||||
|
let url = 'tasks'
|
||||||
|
if (completed !== null) {
|
||||||
|
url += `?completed=${completed}`
|
||||||
|
}
|
||||||
|
const res = await client.get(url)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
today: async () => {
|
||||||
|
const res = await client.get('tasks/today')
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
get: async (id) => {
|
||||||
|
const res = await client.get(`tasks/${id}`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data) => {
|
||||||
|
const res = await client.post('tasks', data)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id, data) => {
|
||||||
|
const res = await client.put(`tasks/${id}`, data)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id) => {
|
||||||
|
await client.delete(`tasks/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
complete: async (id) => {
|
||||||
|
const res = await client.post(`tasks/${id}/complete`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
uncomplete: async (id) => {
|
||||||
|
const res = await client.post(`tasks/${id}/uncomplete`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
}
|
||||||
326
src/components/CreateHabitModal.jsx
Normal file
326
src/components/CreateHabitModal.jsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'#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: ['⭐', '🎉', '✨', '🔥', '🌟', '💎', '🎁'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
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: 'Воскресенье' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function CreateHabitModal({ open, onClose }) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [color, setColor] = useState(COLORS[0])
|
||||||
|
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 [showAllIcons, setShowAllIcons] = useState(false)
|
||||||
|
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (data) => habitsApi.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['habits'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stats'] })
|
||||||
|
handleClose()
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setError(err.response?.data?.error || 'Ошибка создания')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setName('')
|
||||||
|
setDescription('')
|
||||||
|
setColor(COLORS[0])
|
||||||
|
setIcon('✨')
|
||||||
|
setFrequency('daily')
|
||||||
|
setTargetDays([1, 2, 3, 4, 5, 6, 7])
|
||||||
|
setError('')
|
||||||
|
setShowAllIcons(false)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError('Введи название привычки')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (frequency === 'weekly' && targetDays.length === 0) {
|
||||||
|
setError('Выбери хотя бы один день недели')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = { name, description, color, icon, frequency }
|
||||||
|
if (frequency === 'weekly') {
|
||||||
|
data.target_days = targetDays
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation.mutate(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleDay = (dayId) => {
|
||||||
|
setTargetDays(prev =>
|
||||||
|
prev.includes(dayId)
|
||||||
|
? prev.filter(d => d !== dayId)
|
||||||
|
: [...prev, dayId].sort((a, b) => a - b)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Популярные иконки для быстрого выбора
|
||||||
|
const popularIcons = ['✨', '💪', '📚', '🏃', '💧', '🧘', '💤', '🎯', '✏️', '🍎']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={handleClose}
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 100 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 100 }}
|
||||||
|
className="fixed bottom-0 left-0 right-0 z-50 p-4 sm:p-0 sm:flex sm:items-center sm:justify-center sm:inset-0"
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
|
||||||
|
<div className="sticky top-0 bg-white border-b border-gray-100 px-4 py-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Новая привычка</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="p-2 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 rounded-xl bg-red-50 text-red-600 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
|
Название
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="Например: Пить воду"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
|
Описание (опционально)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="8 стаканов в день"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Периодичность
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Ежедневно
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
По дням недели
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{frequency === 'weekly' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Дни недели
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{DAYS.map((day) => (
|
||||||
|
<button
|
||||||
|
key={day.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleDay(day.id)}
|
||||||
|
className={clsx(
|
||||||
|
'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'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{day.short}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Иконка
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{popularIcons.map((ic) => (
|
||||||
|
<button
|
||||||
|
key={ic}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIcon(ic)}
|
||||||
|
className={clsx(
|
||||||
|
'w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all',
|
||||||
|
icon === ic
|
||||||
|
? 'bg-primary-100 ring-2 ring-primary-500'
|
||||||
|
: 'bg-gray-100 hover:bg-gray-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ic}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAllIcons(!showAllIcons)}
|
||||||
|
className="mt-2 flex items-center gap-1 text-sm text-primary-600 hover:text-primary-700"
|
||||||
|
>
|
||||||
|
{showAllIcons ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
|
{showAllIcons ? 'Скрыть' : 'Все иконки'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showAllIcons && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
className="mt-3 space-y-3"
|
||||||
|
>
|
||||||
|
{ICON_CATEGORIES.map((category) => (
|
||||||
|
<div key={category.name}>
|
||||||
|
<p className="text-xs text-gray-500 mb-1.5">{category.name}</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{category.icons.map((ic) => (
|
||||||
|
<button
|
||||||
|
key={ic}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIcon(ic)}
|
||||||
|
className={clsx(
|
||||||
|
'w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all',
|
||||||
|
icon === ic
|
||||||
|
? 'bg-primary-100 ring-2 ring-primary-500'
|
||||||
|
: 'bg-gray-100 hover:bg-gray-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ic}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Цвет
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
className={clsx(
|
||||||
|
'w-8 h-8 rounded-full transition-all',
|
||||||
|
color === c ? 'ring-2 ring-offset-2 ring-gray-400 scale-110' : ''
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="btn btn-primary w-full"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? 'Создаём...' : 'Создать привычку'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
327
src/components/CreateTaskModal.jsx
Normal file
327
src/components/CreateTaskModal.jsx
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { X, ChevronDown, ChevronUp, Calendar } from 'lucide-react'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { tasksApi } from '../api/tasks'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { format, addDays } from 'date-fns'
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'#6B7280', '#6366f1', '#8b5cf6', '#d946ef', '#ec4899',
|
||||||
|
'#f43f5e', '#f97316', '#eab308', '#22c55e', '#0ea5e9',
|
||||||
|
]
|
||||||
|
|
||||||
|
const ICON_CATEGORIES = [
|
||||||
|
{ name: 'Продуктивность', icons: ['📋', '📝', '✅', '📌', '🎯', '💡', '📅', '⏰'] },
|
||||||
|
{ name: 'Работа', icons: ['💼', '💻', '📧', '📞', '📊', '📈', '🖥️', '⌨️'] },
|
||||||
|
{ name: 'Дом', icons: ['🏠', '🧹', '🧺', '🍳', '🛒', '🔧', '🪴', '🛋️'] },
|
||||||
|
{ name: 'Финансы', icons: ['💰', '💳', '📊', '💵', '🏦', '🧾'] },
|
||||||
|
{ name: 'Здоровье', icons: ['💊', '🏃', '🧘', '💪', '🩺', '🦷'] },
|
||||||
|
{ name: 'Разное', icons: ['⭐', '🎁', '📦', '✈️', '🚗', '📷', '🎉'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PRIORITIES = [
|
||||||
|
{ value: 0, label: 'Без приоритета', color: 'bg-gray-100 text-gray-600' },
|
||||||
|
{ value: 1, label: 'Низкий', color: 'bg-blue-100 text-blue-700' },
|
||||||
|
{ value: 2, label: 'Средний', color: 'bg-yellow-100 text-yellow-700' },
|
||||||
|
{ value: 3, label: 'Высокий', color: 'bg-red-100 text-red-700' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function CreateTaskModal({ open, onClose, defaultDueDate = null }) {
|
||||||
|
const today = format(new Date(), 'yyyy-MM-dd')
|
||||||
|
const tomorrow = format(addDays(new Date(), 1), 'yyyy-MM-dd')
|
||||||
|
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [color, setColor] = useState(COLORS[0])
|
||||||
|
const [icon, setIcon] = useState('📋')
|
||||||
|
const [dueDate, setDueDate] = useState(defaultDueDate || today)
|
||||||
|
const [priority, setPriority] = useState(0)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [showAllIcons, setShowAllIcons] = useState(false)
|
||||||
|
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (data) => tasksApi.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks-today'] })
|
||||||
|
handleClose()
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setError(err.response?.data?.error || 'Ошибка создания')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setTitle('')
|
||||||
|
setDescription('')
|
||||||
|
setColor(COLORS[0])
|
||||||
|
setIcon('📋')
|
||||||
|
setDueDate(defaultDueDate || today)
|
||||||
|
setPriority(0)
|
||||||
|
setError('')
|
||||||
|
setShowAllIcons(false)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!title.trim()) {
|
||||||
|
setError('Введи название задачи')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation.mutate({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
due_date: dueDate || null,
|
||||||
|
priority,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const popularIcons = ['📋', '📝', '✅', '🎯', '💼', '🏠', '💰', '📞']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={handleClose}
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 100 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 100 }}
|
||||||
|
className="fixed bottom-0 left-0 right-0 z-50 p-4 sm:p-0 sm:flex sm:items-center sm:justify-center sm:inset-0"
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
|
||||||
|
<div className="sticky top-0 bg-white border-b border-gray-100 px-4 py-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Новая задача</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="p-2 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 rounded-xl bg-red-50 text-red-600 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
|
Название
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="Что нужно сделать?"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
|
Описание (опционально)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
className="input min-h-[80px] resize-none"
|
||||||
|
placeholder="Подробности задачи..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Срок выполнения
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDueDate(today)}
|
||||||
|
className={clsx(
|
||||||
|
'px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
|
||||||
|
dueDate === today
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Сегодня
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDueDate(tomorrow)}
|
||||||
|
className={clsx(
|
||||||
|
'px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
|
||||||
|
dueDate === tomorrow
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Завтра
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDueDate('')}
|
||||||
|
className={clsx(
|
||||||
|
'px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
|
||||||
|
!dueDate
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Без срока
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dueDate}
|
||||||
|
onChange={(e) => setDueDate(e.target.value)}
|
||||||
|
className="input pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Приоритет
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{PRIORITIES.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPriority(p.value)}
|
||||||
|
className={clsx(
|
||||||
|
'px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
|
||||||
|
priority === p.value
|
||||||
|
? p.color + ' ring-2 ring-offset-1 ring-gray-400'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Иконка
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{popularIcons.map((ic) => (
|
||||||
|
<button
|
||||||
|
key={ic}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIcon(ic)}
|
||||||
|
className={clsx(
|
||||||
|
'w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all',
|
||||||
|
icon === ic
|
||||||
|
? 'bg-primary-100 ring-2 ring-primary-500'
|
||||||
|
: 'bg-gray-100 hover:bg-gray-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ic}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAllIcons(!showAllIcons)}
|
||||||
|
className="mt-2 flex items-center gap-1 text-sm text-primary-600 hover:text-primary-700"
|
||||||
|
>
|
||||||
|
{showAllIcons ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
|
{showAllIcons ? 'Скрыть' : 'Все иконки'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showAllIcons && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
className="mt-3 space-y-3"
|
||||||
|
>
|
||||||
|
{ICON_CATEGORIES.map((category) => (
|
||||||
|
<div key={category.name}>
|
||||||
|
<p className="text-xs text-gray-500 mb-1.5">{category.name}</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{category.icons.map((ic) => (
|
||||||
|
<button
|
||||||
|
key={ic}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIcon(ic)}
|
||||||
|
className={clsx(
|
||||||
|
'w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all',
|
||||||
|
icon === ic
|
||||||
|
? 'bg-primary-100 ring-2 ring-primary-500'
|
||||||
|
: 'bg-gray-100 hover:bg-gray-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ic}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Цвет
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
className={clsx(
|
||||||
|
'w-8 h-8 rounded-full transition-all',
|
||||||
|
color === c ? 'ring-2 ring-offset-2 ring-gray-400 scale-110' : ''
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="btn btn-primary w-full"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? 'Создаём...' : 'Создать задачу'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
388
src/components/EditHabitModal.jsx
Normal file
388
src/components/EditHabitModal.jsx
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'#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: ['⭐', '🎉', '✨', '🔥', '🌟', '💎', '🎁'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DAYS = [
|
||||||
|
{ 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 [color, setColor] = useState(COLORS[0])
|
||||||
|
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 [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
|
const [showAllIcons, setShowAllIcons] = useState(false)
|
||||||
|
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (habit && open) {
|
||||||
|
setName(habit.name || '')
|
||||||
|
setDescription(habit.description || '')
|
||||||
|
setColor(habit.color || COLORS[0])
|
||||||
|
setIcon(habit.icon || '✨')
|
||||||
|
setFrequency(habit.frequency || 'daily')
|
||||||
|
setTargetDays(habit.target_days || [1, 2, 3, 4, 5, 6, 7])
|
||||||
|
setError('')
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
setShowAllIcons(false)
|
||||||
|
}
|
||||||
|
}, [habit, open])
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data) => habitsApi.update(habit.id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['habits'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stats'] })
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setError(err.response?.data?.error || 'Ошибка сохранения')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => habitsApi.delete(habit.id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['habits'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stats'] })
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setError(err.response?.data?.error || 'Ошибка удаления')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setError('')
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
setShowAllIcons(false)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError('Введи название привычки')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (frequency === 'weekly' && targetDays.length === 0) {
|
||||||
|
setError('Выбери хотя бы один день недели')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = { name, description, color, icon, frequency }
|
||||||
|
if (frequency === 'weekly') {
|
||||||
|
data.target_days = targetDays
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMutation.mutate(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
deleteMutation.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleDay = (dayId) => {
|
||||||
|
setTargetDays(prev =>
|
||||||
|
prev.includes(dayId)
|
||||||
|
? prev.filter(d => d !== dayId)
|
||||||
|
: [...prev, dayId].sort((a, b) => a - b)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const popularIcons = ['✨', '💪', '📚', '🏃', '💧', '🧘', '💤', '🎯', '✏️', '🍎']
|
||||||
|
|
||||||
|
if (!habit) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={handleClose}
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 100 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 100 }}
|
||||||
|
className="fixed bottom-0 left-0 right-0 z-50 p-4 sm:p-0 sm:flex sm:items-center sm:justify-center sm:inset-0"
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
|
||||||
|
<div className="sticky top-0 bg-white border-b border-gray-100 px-4 py-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Редактировать привычку</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="p-2 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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">
|
||||||
|
Привычка "{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"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="flex-1 btn bg-red-500 text-white hover:bg-red-600"
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? 'Удаляем...' : 'Удалить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="Например: Пить воду"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
|
Описание (опционально)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="8 стаканов в день"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Периодичность
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Ежедневно
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
По дням недели
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{frequency === 'weekly' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Дни недели
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{DAYS.map((day) => (
|
||||||
|
<button
|
||||||
|
key={day.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleDay(day.id)}
|
||||||
|
className={clsx(
|
||||||
|
'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'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{day.short}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Иконка
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{popularIcons.map((ic) => (
|
||||||
|
<button
|
||||||
|
key={ic}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIcon(ic)}
|
||||||
|
className={clsx(
|
||||||
|
'w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all',
|
||||||
|
icon === ic
|
||||||
|
? 'bg-primary-100 ring-2 ring-primary-500'
|
||||||
|
: 'bg-gray-100 hover:bg-gray-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ic}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAllIcons(!showAllIcons)}
|
||||||
|
className="mt-2 flex items-center gap-1 text-sm text-primary-600 hover:text-primary-700"
|
||||||
|
>
|
||||||
|
{showAllIcons ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
|
{showAllIcons ? 'Скрыть' : 'Все иконки'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showAllIcons && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
className="mt-3 space-y-3"
|
||||||
|
>
|
||||||
|
{ICON_CATEGORIES.map((category) => (
|
||||||
|
<div key={category.name}>
|
||||||
|
<p className="text-xs text-gray-500 mb-1.5">{category.name}</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{category.icons.map((ic) => (
|
||||||
|
<button
|
||||||
|
key={ic}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIcon(ic)}
|
||||||
|
className={clsx(
|
||||||
|
'w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all',
|
||||||
|
icon === ic
|
||||||
|
? 'bg-primary-100 ring-2 ring-primary-500'
|
||||||
|
: 'bg-gray-100 hover:bg-gray-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ic}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Цвет
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
className={clsx(
|
||||||
|
'w-8 h-8 rounded-full transition-all',
|
||||||
|
color === c ? 'ring-2 ring-offset-2 ring-gray-400 scale-110' : ''
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 space-y-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
className="btn btn-primary w-full"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? 'Сохраняем...' : 'Сохранить изменения'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
Удалить привычку
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
390
src/components/EditTaskModal.jsx
Normal file
390
src/components/EditTaskModal.jsx
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'#6B7280', '#6366f1', '#8b5cf6', '#d946ef', '#ec4899',
|
||||||
|
'#f43f5e', '#f97316', '#eab308', '#22c55e', '#0ea5e9',
|
||||||
|
]
|
||||||
|
|
||||||
|
const ICON_CATEGORIES = [
|
||||||
|
{ name: 'Продуктивность', icons: ['📋', '📝', '✅', '📌', '🎯', '💡', '📅', '⏰'] },
|
||||||
|
{ name: 'Работа', icons: ['💼', '💻', '📧', '📞', '📊', '📈', '🖥️', '⌨️'] },
|
||||||
|
{ name: 'Дом', icons: ['🏠', '🧹', '🧺', '🍳', '🛒', '🔧', '🪴', '🛋️'] },
|
||||||
|
{ name: 'Финансы', icons: ['💰', '💳', '📊', '💵', '🏦', '🧾'] },
|
||||||
|
{ name: 'Здоровье', icons: ['💊', '🏃', '🧘', '💪', '🩺', '🦷'] },
|
||||||
|
{ name: 'Разное', icons: ['⭐', '🎁', '📦', '✈️', '🚗', '📷', '🎉'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PRIORITIES = [
|
||||||
|
{ value: 0, label: 'Без приоритета', color: 'bg-gray-100 text-gray-600' },
|
||||||
|
{ value: 1, label: 'Низкий', color: 'bg-blue-100 text-blue-700' },
|
||||||
|
{ value: 2, label: 'Средний', color: 'bg-yellow-100 text-yellow-700' },
|
||||||
|
{ value: 3, label: 'Высокий', color: 'bg-red-100 text-red-700' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function EditTaskModal({ open, onClose, task }) {
|
||||||
|
const today = format(new Date(), 'yyyy-MM-dd')
|
||||||
|
const tomorrow = format(addDays(new Date(), 1), 'yyyy-MM-dd')
|
||||||
|
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [color, setColor] = useState(COLORS[0])
|
||||||
|
const [icon, setIcon] = useState('📋')
|
||||||
|
const [dueDate, setDueDate] = useState('')
|
||||||
|
const [priority, setPriority] = useState(0)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
|
const [showAllIcons, setShowAllIcons] = useState(false)
|
||||||
|
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (task && open) {
|
||||||
|
setTitle(task.title || '')
|
||||||
|
setDescription(task.description || '')
|
||||||
|
setColor(task.color || COLORS[0])
|
||||||
|
setIcon(task.icon || '📋')
|
||||||
|
setDueDate(task.due_date || '')
|
||||||
|
setPriority(task.priority || 0)
|
||||||
|
setError('')
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
setShowAllIcons(false)
|
||||||
|
}
|
||||||
|
}, [task, open])
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data) => tasksApi.update(task.id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks-today'] })
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setError(err.response?.data?.error || 'Ошибка сохранения')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => tasksApi.delete(task.id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks-today'] })
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setError(err.response?.data?.error || 'Ошибка удаления')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setError('')
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
setShowAllIcons(false)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!title.trim()) {
|
||||||
|
setError('Введи название задачи')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMutation.mutate({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
due_date: dueDate || null,
|
||||||
|
priority,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
deleteMutation.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const popularIcons = ['📋', '📝', '✅', '🎯', '💼', '🏠', '💰', '📞']
|
||||||
|
|
||||||
|
if (!task) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={handleClose}
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 100 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 100 }}
|
||||||
|
className="fixed bottom-0 left-0 right-0 z-50 p-4 sm:p-0 sm:flex sm:items-center sm:justify-center sm:inset-0"
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
|
||||||
|
<div className="sticky top-0 bg-white border-b border-gray-100 px-4 py-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Редактировать задачу</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="p-2 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
</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 mb-1.5">
|
||||||
|
Описание (опционально)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
className="input min-h-[80px] resize-none"
|
||||||
|
placeholder="Подробности задачи..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Срок выполнения
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDueDate(today)}
|
||||||
|
className={clsx(
|
||||||
|
'px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
|
||||||
|
dueDate === today
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Сегодня
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDueDate(tomorrow)}
|
||||||
|
className={clsx(
|
||||||
|
'px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
|
||||||
|
dueDate === tomorrow
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Завтра
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDueDate('')}
|
||||||
|
className={clsx(
|
||||||
|
'px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
|
||||||
|
!dueDate
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Без срока
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dueDate}
|
||||||
|
onChange={(e) => setDueDate(e.target.value)}
|
||||||
|
className="input pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Приоритет
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{PRIORITIES.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPriority(p.value)}
|
||||||
|
className={clsx(
|
||||||
|
'px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
|
||||||
|
priority === p.value
|
||||||
|
? p.color + ' ring-2 ring-offset-1 ring-gray-400'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Иконка
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{popularIcons.map((ic) => (
|
||||||
|
<button
|
||||||
|
key={ic}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIcon(ic)}
|
||||||
|
className={clsx(
|
||||||
|
'w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all',
|
||||||
|
icon === ic
|
||||||
|
? 'bg-primary-100 ring-2 ring-primary-500'
|
||||||
|
: 'bg-gray-100 hover:bg-gray-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ic}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAllIcons(!showAllIcons)}
|
||||||
|
className="mt-2 flex items-center gap-1 text-sm text-primary-600 hover:text-primary-700"
|
||||||
|
>
|
||||||
|
{showAllIcons ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
|
{showAllIcons ? 'Скрыть' : 'Все иконки'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showAllIcons && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
className="mt-3 space-y-3"
|
||||||
|
>
|
||||||
|
{ICON_CATEGORIES.map((category) => (
|
||||||
|
<div key={category.name}>
|
||||||
|
<p className="text-xs text-gray-500 mb-1.5">{category.name}</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{category.icons.map((ic) => (
|
||||||
|
<button
|
||||||
|
key={ic}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIcon(ic)}
|
||||||
|
className={clsx(
|
||||||
|
'w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all',
|
||||||
|
icon === ic
|
||||||
|
? 'bg-primary-100 ring-2 ring-primary-500'
|
||||||
|
: 'bg-gray-100 hover:bg-gray-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ic}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Цвет
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
className={clsx(
|
||||||
|
'w-8 h-8 rounded-full transition-all',
|
||||||
|
color === c ? 'ring-2 ring-offset-2 ring-gray-400 scale-110' : ''
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 space-y-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
className="btn btn-primary w-full"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? 'Сохраняем...' : 'Сохранить изменения'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
Удалить задачу
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
38
src/components/Navigation.jsx
Normal file
38
src/components/Navigation.jsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { NavLink } from 'react-router-dom'
|
||||||
|
import { Home, ListChecks, CheckSquare, BarChart3 } 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: 'Статистика' },
|
||||||
|
]
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="flex items-center justify-around py-2">
|
||||||
|
{navItems.map(({ to, icon: Icon, label }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
clsx(
|
||||||
|
'flex flex-col items-center gap-1 px-3 py-2 rounded-xl transition-all',
|
||||||
|
isActive
|
||||||
|
? 'text-primary-600 bg-primary-50'
|
||||||
|
: 'text-gray-400 hover:text-gray-600'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon size={22} />
|
||||||
|
<span className="text-xs font-medium">{label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
src/index.css
Normal file
85
src/index.css
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
@import url('https://api.fontshare.com/v2/css?f[]=satoshi@700,500,400&display=swap');
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-surface-50 text-gray-900 antialiased;
|
||||||
|
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn {
|
||||||
|
@apply inline-flex items-center justify-center px-5 py-3 rounded-2xl font-semibold transition-all duration-300;
|
||||||
|
@apply focus:outline-none focus:ring-2 focus:ring-offset-2;
|
||||||
|
@apply disabled:opacity-50 disabled:cursor-not-allowed;
|
||||||
|
@apply active:scale-95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
@apply bg-gradient-to-r from-primary-600 to-primary-700 text-white;
|
||||||
|
@apply hover:from-primary-700 hover:to-primary-800;
|
||||||
|
@apply focus:ring-primary-500;
|
||||||
|
@apply shadow-lg shadow-primary-500/25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent {
|
||||||
|
@apply bg-gradient-to-r from-accent-400 to-accent-500 text-white;
|
||||||
|
@apply hover:from-accent-500 hover:to-accent-600;
|
||||||
|
@apply focus:ring-accent-400;
|
||||||
|
@apply shadow-lg shadow-accent-400/25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
@apply bg-white rounded-3xl shadow-sm border border-gray-100/50;
|
||||||
|
@apply hover:shadow-md transition-shadow duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-glass {
|
||||||
|
@apply bg-white/70 backdrop-blur-xl rounded-3xl;
|
||||||
|
@apply border border-white/20 shadow-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-mesh {
|
||||||
|
background:
|
||||||
|
radial-gradient(at 40% 20%, rgba(20, 184, 166, 0.1) 0px, transparent 50%),
|
||||||
|
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%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
@apply bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-gray-300 rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
@apply bg-gray-400;
|
||||||
|
}
|
||||||
25
src/main.jsx
Normal file
25
src/main.jsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
138
src/pages/ForgotPassword.jsx
Normal file
138
src/pages/ForgotPassword.jsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Mail, ArrowLeft, Zap, CheckCircle } from 'lucide-react'
|
||||||
|
import api from '../api/client'
|
||||||
|
|
||||||
|
export default function ForgotPassword() {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [sent, setSent] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post('/auth/forgot-password', { email })
|
||||||
|
setSent(true)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || 'Ошибка отправки')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4 gradient-mesh bg-surface-50">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="w-full max-w-md"
|
||||||
|
>
|
||||||
|
<div className="card p-10 text-center">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 200 }}
|
||||||
|
className="w-20 h-20 rounded-3xl bg-green-100 flex items-center justify-center mx-auto mb-6"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-10 h-10 text-green-600" />
|
||||||
|
</motion.div>
|
||||||
|
<h1 className="text-2xl font-display font-bold text-gray-900 mb-2">
|
||||||
|
Письмо отправлено! 📬
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mb-6">
|
||||||
|
Если аккаунт с email <strong>{email}</strong> существует, мы отправили ссылку для сброса пароля.
|
||||||
|
</p>
|
||||||
|
<Link to="/login" className="btn btn-primary">
|
||||||
|
Вернуться ко входу
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }}
|
||||||
|
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-20 h-20 rounded-3xl bg-gradient-to-br from-primary-500 to-primary-700 mb-6 shadow-xl shadow-primary-500/30"
|
||||||
|
>
|
||||||
|
<Mail className="w-10 h-10 text-white" />
|
||||||
|
</motion.div>
|
||||||
|
<h1 className="text-3xl font-display font-bold text-gray-900">
|
||||||
|
Забыли пароль?
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-2">
|
||||||
|
Введи email и мы отправим ссылку для сброса
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div 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"
|
||||||
|
>
|
||||||
|
{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
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="btn btn-primary w-full text-lg"
|
||||||
|
>
|
||||||
|
{loading ? 'Отправляем...' : 'Отправить ссылку'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="inline-flex items-center gap-2 text-primary-600 hover:text-primary-700 font-medium text-sm"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Вернуться ко входу
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-2 mt-6 text-gray-400">
|
||||||
|
<Zap size={16} />
|
||||||
|
<span className="text-sm font-medium">Pulse</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
274
src/pages/Habits.jsx
Normal file
274
src/pages/Habits.jsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { Plus, Settings, Flame, Calendar, ChevronRight, Archive, ArchiveRestore } from 'lucide-react'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { ru } from 'date-fns/locale'
|
||||||
|
import { habitsApi } from '../api/habits'
|
||||||
|
import CreateHabitModal from '../components/CreateHabitModal'
|
||||||
|
import EditHabitModal from '../components/EditHabitModal'
|
||||||
|
import Navigation from '../components/Navigation'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
export default function Habits() {
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
|
const [editingHabit, setEditingHabit] = useState(null)
|
||||||
|
const [showArchived, setShowArchived] = useState(false)
|
||||||
|
const [habitStats, setHabitStats] = useState({})
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const { data: habits = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['habits', showArchived],
|
||||||
|
queryFn: () => habitsApi.list().then(h => showArchived ? h : h.filter(x => !x.is_archived)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: archivedHabits = [] } = useQuery({
|
||||||
|
queryKey: ['habits-archived'],
|
||||||
|
queryFn: () => habitsApi.list().then(h => h.filter(x => x.is_archived)),
|
||||||
|
enabled: showArchived,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Загружаем статистику для каждой привычки
|
||||||
|
useEffect(() => {
|
||||||
|
if (habits.length > 0) {
|
||||||
|
loadStats()
|
||||||
|
}
|
||||||
|
}, [habits])
|
||||||
|
|
||||||
|
const loadStats = async () => {
|
||||||
|
const statsMap = {}
|
||||||
|
await Promise.all(habits.map(async (habit) => {
|
||||||
|
try {
|
||||||
|
const stats = await habitsApi.getHabitStats(habit.id)
|
||||||
|
statsMap[habit.id] = stats
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading stats for habit', habit.id, e)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
setHabitStats(statsMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiveMutation = useMutation({
|
||||||
|
mutationFn: ({ id, archived }) => habitsApi.update(id, { is_archived: archived }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['habits'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['habits-archived'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stats'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const getFrequencyLabel = (habit) => {
|
||||||
|
if (habit.frequency === 'daily') return 'Ежедневно'
|
||||||
|
if (habit.frequency === 'weekly' && habit.target_days) {
|
||||||
|
const days = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
|
||||||
|
return habit.target_days.map(d => days[d - 1]).join(', ')
|
||||||
|
}
|
||||||
|
if (habit.frequency === 'custom') {
|
||||||
|
return `Каждые ${habit.target_count} дн.`
|
||||||
|
}
|
||||||
|
return habit.frequency
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeHabits = habits.filter(h => !h.is_archived)
|
||||||
|
const archivedList = habits.filter(h => h.is_archived)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-surface-50 gradient-mesh pb-24">
|
||||||
|
<header className="bg-white/70 backdrop-blur-xl border-b border-gray-100/50 sticky top-0 z-10">
|
||||||
|
<div className="max-w-lg mx-auto px-4 py-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-display font-bold text-gray-900">Мои привычки</h1>
|
||||||
|
<p className="text-sm text-gray-500">{activeHabits.length} активных</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="btn btn-primary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
Новая
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-lg mx-auto px-4 py-6 space-y-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="card p-5 animate-pulse">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-gray-200" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-5 bg-gray-200 rounded-lg w-1/2 mb-2" />
|
||||||
|
<div className="h-4 bg-gray-200 rounded-lg w-1/3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : activeHabits.length === 0 && !showArchived ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="card p-10 text-center"
|
||||||
|
>
|
||||||
|
<div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-primary-100 to-accent-100 flex items-center justify-center mx-auto mb-5">
|
||||||
|
<Plus className="w-10 h-10 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-display font-bold text-gray-900 mb-2">Нет привычек</h3>
|
||||||
|
<p className="text-gray-500 mb-6">Создай свою первую привычку!</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
<Plus size={20} className="mr-2" />
|
||||||
|
Создать привычку
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Активные привычки */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<AnimatePresence>
|
||||||
|
{activeHabits.map((habit, index) => (
|
||||||
|
<HabitListItem
|
||||||
|
key={habit.id}
|
||||||
|
habit={habit}
|
||||||
|
index={index}
|
||||||
|
stats={habitStats[habit.id]}
|
||||||
|
frequencyLabel={getFrequencyLabel(habit)}
|
||||||
|
onEdit={() => setEditingHabit(habit)}
|
||||||
|
onArchive={() => archiveMutation.mutate({ id: habit.id, archived: true })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Архивные привычки */}
|
||||||
|
{archivedList.length > 0 && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowArchived(!showArchived)}
|
||||||
|
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 mb-4"
|
||||||
|
>
|
||||||
|
<Archive size={18} />
|
||||||
|
<span className="font-medium">Архив ({archivedList.length})</span>
|
||||||
|
<ChevronRight
|
||||||
|
size={18}
|
||||||
|
className={clsx(
|
||||||
|
'transition-transform',
|
||||||
|
showArchived && 'rotate-90'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showArchived && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
{archivedList.map((habit, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={habit.id}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.05 }}
|
||||||
|
className="card p-4 opacity-60"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-xl flex items-center justify-center text-xl"
|
||||||
|
style={{ backgroundColor: habit.color + '20' }}
|
||||||
|
>
|
||||||
|
{habit.icon || '✨'}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-gray-600 truncate">{habit.name}</h3>
|
||||||
|
<p className="text-sm text-gray-400">{getFrequencyLabel(habit)}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => archiveMutation.mutate({ id: habit.id, archived: false })}
|
||||||
|
className="p-2 text-gray-400 hover:text-green-500 hover:bg-green-50 rounded-xl transition-all"
|
||||||
|
title="Восстановить"
|
||||||
|
>
|
||||||
|
<ArchiveRestore size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Navigation />
|
||||||
|
|
||||||
|
<CreateHabitModal
|
||||||
|
open={showCreateModal}
|
||||||
|
onClose={() => setShowCreateModal(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditHabitModal
|
||||||
|
open={!!editingHabit}
|
||||||
|
onClose={() => setEditingHabit(null)}
|
||||||
|
habit={editingHabit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HabitListItem({ habit, index, stats, frequencyLabel, onEdit, onArchive }) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -100 }}
|
||||||
|
transition={{ delay: index * 0.05 }}
|
||||||
|
onClick={onEdit}
|
||||||
|
className="card p-4 cursor-pointer hover:shadow-lg transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
className="w-14 h-14 rounded-2xl flex items-center justify-center text-2xl flex-shrink-0"
|
||||||
|
style={{ backgroundColor: habit.color + '15' }}
|
||||||
|
>
|
||||||
|
{habit.icon || '✨'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-gray-900 truncate">{habit.name}</h3>
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
<span
|
||||||
|
className="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||||
|
style={{ backgroundColor: habit.color + '15', color: habit.color }}
|
||||||
|
>
|
||||||
|
{frequencyLabel}
|
||||||
|
</span>
|
||||||
|
{stats && stats.current_streak > 0 && (
|
||||||
|
<span className="text-xs text-orange-500 flex items-center gap-1">
|
||||||
|
<Flame size={14} />
|
||||||
|
{stats.current_streak} дн.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{stats && (
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-semibold text-gray-900">{stats.this_month}</p>
|
||||||
|
<p className="text-xs text-gray-400">в месяц</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ChevronRight size={20} className="text-gray-300" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
610
src/pages/Home.jsx
Normal file
610
src/pages/Home.jsx
Normal file
@@ -0,0 +1,610 @@
|
|||||||
|
import { useState, useEffect, useMemo } 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 { 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 clsx from 'clsx'
|
||||||
|
|
||||||
|
// Определение "сегодняшних" привычек
|
||||||
|
function shouldShowToday(habit, lastLogDate) {
|
||||||
|
const today = new Date()
|
||||||
|
const dayOfWeek = today.getDay() || 7 // 1=Пн, 7=Вс (JS: 0=Вс -> 7)
|
||||||
|
|
||||||
|
if (habit.frequency === 'daily') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (habit.frequency === 'weekly') {
|
||||||
|
// Проверяем, выбран ли сегодняшний день
|
||||||
|
if (habit.target_days && habit.target_days.includes(dayOfWeek)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если не выполнялась на этой неделе
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
return daysSinceLastLog >= habit.target_count
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDueDate(dateStr) {
|
||||||
|
if (!dateStr) return null
|
||||||
|
const date = parseISO(dateStr)
|
||||||
|
if (isToday(date)) return 'Сегодня'
|
||||||
|
if (isTomorrow(date)) return 'Завтра'
|
||||||
|
return format(date, 'd MMM', { locale: ru })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [todayLogs, setTodayLogs] = useState({})
|
||||||
|
const [lastLogDates, setLastLogDates] = useState({})
|
||||||
|
const [showCreateTask, setShowCreateTask] = useState(false)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { user, logout } = useAuthStore()
|
||||||
|
|
||||||
|
const { data: habits = [], isLoading: habitsLoading } = useQuery({
|
||||||
|
queryKey: ['habits'],
|
||||||
|
queryFn: habitsApi.list,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: stats } = useQuery({
|
||||||
|
queryKey: ['stats'],
|
||||||
|
queryFn: habitsApi.getStats,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: todayTasks = [], isLoading: tasksLoading } = useQuery({
|
||||||
|
queryKey: ['tasks-today'],
|
||||||
|
queryFn: tasksApi.today,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (habits.length > 0) {
|
||||||
|
loadTodayLogs()
|
||||||
|
}
|
||||||
|
}, [habits])
|
||||||
|
|
||||||
|
const loadTodayLogs = async () => {
|
||||||
|
const today = format(new Date(), 'yyyy-MM-dd')
|
||||||
|
const logsMap = {}
|
||||||
|
const lastDates = {}
|
||||||
|
|
||||||
|
await Promise.all(habits.map(async (habit) => {
|
||||||
|
try {
|
||||||
|
const logs = await habitsApi.getLogs(habit.id, 30)
|
||||||
|
|
||||||
|
// Находим последний лог
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading logs for habit', habit.id, e)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
setTodayLogs(logsMap)
|
||||||
|
setLastLogDates(lastDates)
|
||||||
|
}
|
||||||
|
|
||||||
|
const logMutation = useMutation({
|
||||||
|
mutationFn: (habitId) => habitsApi.log(habitId),
|
||||||
|
onSuccess: (data, habitId) => {
|
||||||
|
const today = format(new Date(), 'yyyy-MM-dd')
|
||||||
|
setTodayLogs(prev => ({ ...prev, [habitId]: data.id }))
|
||||||
|
setLastLogDates(prev => ({ ...prev, [habitId]: today }))
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['habits'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stats'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteLogMutation = useMutation({
|
||||||
|
mutationFn: ({ habitId, logId }) => habitsApi.deleteLog(habitId, logId),
|
||||||
|
onSuccess: (_, { habitId }) => {
|
||||||
|
setTodayLogs(prev => {
|
||||||
|
const newLogs = { ...prev }
|
||||||
|
delete newLogs[habitId]
|
||||||
|
return newLogs
|
||||||
|
})
|
||||||
|
// Перезагружаем логи для обновления lastLogDate
|
||||||
|
loadTodayLogs()
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['habits'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stats'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const completeTaskMutation = useMutation({
|
||||||
|
mutationFn: (id) => tasksApi.complete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks-today'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const uncompleteTaskMutation = useMutation({
|
||||||
|
mutationFn: (id) => tasksApi.uncomplete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks-today'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleToggleComplete = (habitId) => {
|
||||||
|
if (todayLogs[habitId]) {
|
||||||
|
deleteLogMutation.mutate({ habitId, logId: todayLogs[habitId] })
|
||||||
|
} else {
|
||||||
|
logMutation.mutate(habitId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleTask = (task) => {
|
||||||
|
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])
|
||||||
|
|
||||||
|
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="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">
|
||||||
|
Привет, {user?.username}!
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500 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"
|
||||||
|
title="Выйти"
|
||||||
|
>
|
||||||
|
<LogOut size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: totalToday > 0 ? `${(completedCount / totalToday) * 100}%` : '0%' }}
|
||||||
|
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||||
|
className="h-full bg-gradient-to-r from-primary-500 to-accent-500 rounded-full"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Статистика */}
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Задачи на сегодня */}
|
||||||
|
{(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>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateTask(true)}
|
||||||
|
className="p-2 bg-primary-100 text-primary-600 rounded-xl hover:bg-primary-200 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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="flex-1">
|
||||||
|
<div className="h-5 bg-gray-200 rounded-lg w-3/4 mb-2" />
|
||||||
|
<div className="h-4 bg-gray-200 rounded-lg w-1/4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : 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"
|
||||||
|
>
|
||||||
|
+ Добавить задачу
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Привычки на сегодня */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-display font-bold text-gray-900 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="flex-1">
|
||||||
|
<div className="h-5 bg-gray-200 rounded-lg w-1/2 mb-2" />
|
||||||
|
<div className="h-4 bg-gray-200 rounded-lg w-1/3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : 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" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-display font-bold text-gray-900 mb-2">Свободный день!</h3>
|
||||||
|
<p className="text-gray-500">На сегодня нет запланированных привычек. Отдохни или добавь новую во вкладке "Привычки".</p>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<AnimatePresence>
|
||||||
|
{todayHabits.map((habit, index) => (
|
||||||
|
<HabitCard
|
||||||
|
key={habit.id}
|
||||||
|
habit={habit}
|
||||||
|
index={index}
|
||||||
|
isCompleted={!!todayLogs[habit.id]}
|
||||||
|
onToggle={() => handleToggleComplete(habit.id)}
|
||||||
|
isLoading={logMutation.isPending || deleteLogMutation.isPending}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Navigation />
|
||||||
|
|
||||||
|
<CreateTaskModal
|
||||||
|
open={showCreateTask}
|
||||||
|
onClose={() => setShowCreateTask(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskCard({ task, index, onToggle, isLoading }) {
|
||||||
|
const [showConfetti, setShowConfetti] = useState(false)
|
||||||
|
const dueDateLabel = formatDueDate(task.due_date)
|
||||||
|
const isOverdue = task.due_date && isPast(parseISO(task.due_date)) && !isToday(parseISO(task.due_date)) && !task.completed
|
||||||
|
|
||||||
|
const handleCheck = (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (isLoading) return
|
||||||
|
if (!task.completed) {
|
||||||
|
setShowConfetti(true)
|
||||||
|
setTimeout(() => setShowConfetti(false), 1000)
|
||||||
|
}
|
||||||
|
onToggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -100 }}
|
||||||
|
transition={{ delay: index * 0.05 }}
|
||||||
|
className="card p-4 relative overflow-hidden"
|
||||||
|
>
|
||||||
|
{showConfetti && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 1 }}
|
||||||
|
animate={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 1 }}
|
||||||
|
className="absolute inset-0 pointer-events-none"
|
||||||
|
>
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ x: '50%', y: '50%', scale: 0 }}
|
||||||
|
animate={{
|
||||||
|
x: `${Math.random() * 100}%`,
|
||||||
|
y: `${Math.random() * 100}%`,
|
||||||
|
scale: [0, 1, 0]
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.6, delay: i * 0.05 }}
|
||||||
|
className="absolute w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: task.color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<motion.button
|
||||||
|
onClick={handleCheck}
|
||||||
|
disabled={isLoading}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
className={clsx(
|
||||||
|
'w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 flex-shrink-0',
|
||||||
|
task.completed
|
||||||
|
? 'bg-gradient-to-br from-green-400 to-green-500 shadow-lg shadow-green-400/30'
|
||||||
|
: 'border-2 hover:shadow-md'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
borderColor: task.completed ? undefined : task.color + '40',
|
||||||
|
backgroundColor: task.completed ? undefined : task.color + '10'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{task.completed ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0, rotate: -180 }}
|
||||||
|
animate={{ scale: 1, rotate: 0 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 500 }}
|
||||||
|
>
|
||||||
|
<Check className="w-5 h-5 text-white" strokeWidth={3} />
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<span className="text-lg">{task.icon || '📋'}</span>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className={clsx(
|
||||||
|
"font-semibold truncate",
|
||||||
|
task.completed ? "text-gray-400 line-through" : "text-gray-900"
|
||||||
|
)}>{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'
|
||||||
|
)}>
|
||||||
|
{isOverdue && <AlertTriangle size={12} />}
|
||||||
|
<Calendar size={12} />
|
||||||
|
{dueDateLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</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="Отменить"
|
||||||
|
>
|
||||||
|
<Undo2 size={18} />
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HabitCard({ habit, index, isCompleted, onToggle, isLoading }) {
|
||||||
|
const [showConfetti, setShowConfetti] = useState(false)
|
||||||
|
|
||||||
|
const handleCheck = (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (isLoading) return
|
||||||
|
if (!isCompleted) {
|
||||||
|
setShowConfetti(true)
|
||||||
|
setTimeout(() => setShowConfetti(false), 1000)
|
||||||
|
}
|
||||||
|
onToggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -100 }}
|
||||||
|
transition={{ delay: index * 0.05 }}
|
||||||
|
className="card p-5 relative overflow-hidden"
|
||||||
|
>
|
||||||
|
{showConfetti && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 1 }}
|
||||||
|
animate={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 1 }}
|
||||||
|
className="absolute inset-0 pointer-events-none"
|
||||||
|
>
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ x: '50%', y: '50%', scale: 0 }}
|
||||||
|
animate={{
|
||||||
|
x: `${Math.random() * 100}%`,
|
||||||
|
y: `${Math.random() * 100}%`,
|
||||||
|
scale: [0, 1, 0]
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.6, delay: i * 0.05 }}
|
||||||
|
className="absolute w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: habit.color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<motion.button
|
||||||
|
onClick={handleCheck}
|
||||||
|
disabled={isLoading}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
className={clsx(
|
||||||
|
'w-14 h-14 rounded-2xl flex items-center justify-center transition-all duration-300 relative flex-shrink-0',
|
||||||
|
isCompleted
|
||||||
|
? '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'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isCompleted ? (
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<span className="text-2xl">{habit.icon || '✨'}</span>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
159
src/pages/Login.jsx
Normal file
159
src/pages/Login.jsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Eye, EyeOff, Zap } from 'lucide-react'
|
||||||
|
import { useAuthStore } from '../store/auth'
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const login = useAuthStore(s => s.login)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(email, password)
|
||||||
|
navigate('/')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || 'Ошибка входа')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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="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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
className="text-gray-500 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"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 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"
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
Входим...
|
||||||
|
</span>
|
||||||
|
) : 'Войти'}
|
||||||
|
</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>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
136
src/pages/Register.jsx
Normal file
136
src/pages/Register.jsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Eye, EyeOff, Sparkles } from 'lucide-react'
|
||||||
|
import { useAuthStore } from '../store/auth'
|
||||||
|
|
||||||
|
export default function Register() {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const register = useAuthStore(s => s.register)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await register(email, username, password)
|
||||||
|
navigate('/')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || 'Ошибка регистрации')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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="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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
{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
|
||||||
|
/>
|
||||||
|
</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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 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"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
src/pages/ResetPassword.jsx
Normal file
140
src/pages/ResetPassword.jsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useSearchParams, Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Eye, EyeOff, Zap, CheckCircle } from 'lucide-react'
|
||||||
|
import api from '../api/client'
|
||||||
|
|
||||||
|
export default function ResetPassword() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const token = searchParams.get('token')
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!token) {
|
||||||
|
setError('Токен не найден')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post('/auth/reset-password', { token, new_password: password })
|
||||||
|
setSuccess(true)
|
||||||
|
setTimeout(() => navigate('/login'), 2000)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || 'Ошибка сброса пароля')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4 gradient-mesh bg-surface-50">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="card p-10 text-center max-w-md w-full"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 200 }}
|
||||||
|
className="w-20 h-20 rounded-3xl bg-green-100 flex items-center justify-center mx-auto mb-6"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-10 h-10 text-green-600" />
|
||||||
|
</motion.div>
|
||||||
|
<h1 className="text-2xl font-display font-bold text-gray-900 mb-2">
|
||||||
|
Пароль изменён! 🎉
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500">Перенаправляем на страницу входа...</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }}
|
||||||
|
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-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>
|
||||||
|
<h1 className="text-3xl font-display font-bold text-gray-900">
|
||||||
|
Новый пароль
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-2">Придумай новый надёжный пароль</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div 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"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||||
|
Новый пароль
|
||||||
|
</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-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="btn btn-primary w-full text-lg"
|
||||||
|
>
|
||||||
|
{loading ? 'Сохраняем...' : 'Сохранить пароль'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<Link to="/login" className="text-primary-600 hover:text-primary-700 font-medium text-sm">
|
||||||
|
Вернуться ко входу
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
378
src/pages/Stats.jsx
Normal file
378
src/pages/Stats.jsx
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
import { useState, useEffect, useMemo } 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 { ru } from 'date-fns/locale'
|
||||||
|
import { habitsApi } from '../api/habits'
|
||||||
|
import Navigation from '../components/Navigation'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
export default function Stats() {
|
||||||
|
const [currentMonth, setCurrentMonth] = useState(new Date())
|
||||||
|
const [allHabitLogs, setAllHabitLogs] = useState({})
|
||||||
|
const [allHabitStats, setAllHabitStats] = useState({})
|
||||||
|
const [selectedHabitId, setSelectedHabitId] = useState(null)
|
||||||
|
|
||||||
|
const { data: habits = [] } = useQuery({
|
||||||
|
queryKey: ['habits'],
|
||||||
|
queryFn: habitsApi.list,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Загрузка логов и статистики для всех привычек
|
||||||
|
useEffect(() => {
|
||||||
|
if (habits.length > 0) {
|
||||||
|
loadAllHabitsData()
|
||||||
|
}
|
||||||
|
}, [habits])
|
||||||
|
|
||||||
|
const loadAllHabitsData = async () => {
|
||||||
|
const logsMap = {}
|
||||||
|
const statsMap = {}
|
||||||
|
|
||||||
|
await Promise.all(habits.map(async (habit) => {
|
||||||
|
try {
|
||||||
|
const [logs, stats] = await Promise.all([
|
||||||
|
habitsApi.getLogs(habit.id, 90),
|
||||||
|
habitsApi.getHabitStats(habit.id),
|
||||||
|
])
|
||||||
|
logsMap[habit.id] = logs
|
||||||
|
statsMap[habit.id] = stats
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error loading data for habit ${habit.id}:`, e)
|
||||||
|
logsMap[habit.id] = []
|
||||||
|
statsMap[habit.id] = null
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
setAllHabitLogs(logsMap)
|
||||||
|
setAllHabitStats(statsMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => {
|
||||||
|
const logs = allHabitLogs[habit.id] || []
|
||||||
|
return logs.some(log => log.date.split('T')[0] === dateStr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсчёт выполнений за текущий месяц для каждой привычки
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
stats[habit.id] = monthLogs.length
|
||||||
|
})
|
||||||
|
return stats
|
||||||
|
}, [habits, allHabitLogs, currentMonth])
|
||||||
|
|
||||||
|
// Общий % выполнения за месяц
|
||||||
|
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])
|
||||||
|
|
||||||
|
const prevMonth = () => setCurrentMonth(subMonths(currentMonth, 1))
|
||||||
|
const nextMonth = () => setCurrentMonth(addMonths(currentMonth, 1))
|
||||||
|
|
||||||
|
const selectedHabit = habits.find(h => h.id === selectedHabitId)
|
||||||
|
const selectedStats = selectedHabitId ? allHabitStats[selectedHabitId] : null
|
||||||
|
|
||||||
|
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>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-lg mx-auto px-4 py-6 space-y-6">
|
||||||
|
|
||||||
|
{/* Легенда с привычками */}
|
||||||
|
{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 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
className="card p-5"
|
||||||
|
>
|
||||||
|
<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()}
|
||||||
|
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'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Число дня */}
|
||||||
|
<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>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Monthly Summary - карточки для каждой привычки */}
|
||||||
|
{habits.length > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.15 }}
|
||||||
|
className="card p-5"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{habits.length === 0 && (
|
||||||
|
<div className="card p-10 text-center">
|
||||||
|
<p className="text-gray-500">Создайте привычки, чтобы видеть статистику</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Navigation />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
317
src/pages/Tasks.jsx
Normal file
317
src/pages/Tasks.jsx
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { Plus, Check, Circle, Calendar, AlertTriangle, Undo2, Edit2 } from 'lucide-react'
|
||||||
|
import { format, parseISO, isToday, isTomorrow, isPast } from 'date-fns'
|
||||||
|
import { ru } from 'date-fns/locale'
|
||||||
|
import { tasksApi } from '../api/tasks'
|
||||||
|
import Navigation from '../components/Navigation'
|
||||||
|
import CreateTaskModal from '../components/CreateTaskModal'
|
||||||
|
import EditTaskModal from '../components/EditTaskModal'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
const PRIORITY_LABELS = {
|
||||||
|
0: null,
|
||||||
|
1: { label: 'Низкий', class: 'bg-blue-100 text-blue-700' },
|
||||||
|
2: { label: 'Средний', class: 'bg-yellow-100 text-yellow-700' },
|
||||||
|
3: { label: 'Высокий', class: 'bg-red-100 text-red-700' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDueDate(dateStr) {
|
||||||
|
if (!dateStr) return null
|
||||||
|
const date = parseISO(dateStr)
|
||||||
|
if (isToday(date)) return 'Сегодня'
|
||||||
|
if (isTomorrow(date)) return 'Завтра'
|
||||||
|
return format(date, 'd MMM', { locale: ru })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Tasks() {
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [editingTask, setEditingTask] = useState(null)
|
||||||
|
const [filter, setFilter] = useState('active') // all, active, completed
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const { data: tasks = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['tasks', filter],
|
||||||
|
queryFn: () => {
|
||||||
|
if (filter === 'all') return tasksApi.list()
|
||||||
|
return tasksApi.list(filter === 'completed')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const completeMutation = useMutation({
|
||||||
|
mutationFn: (id) => tasksApi.complete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks-today'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const uncompleteMutation = useMutation({
|
||||||
|
mutationFn: (id) => tasksApi.uncomplete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks-today'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleToggle = (task) => {
|
||||||
|
if (task.completed) {
|
||||||
|
uncompleteMutation.mutate(task.id)
|
||||||
|
} else {
|
||||||
|
completeMutation.mutate(task.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTasks = tasks.filter(t => !t.completed)
|
||||||
|
const completedTasks = tasks.filter(t => t.completed)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-surface-50 gradient-mesh pb-24">
|
||||||
|
<header className="bg-white/70 backdrop-blur-xl border-b border-gray-100/50 sticky top-0 z-10">
|
||||||
|
<div className="max-w-lg mx-auto px-4 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-display font-bold text-gray-900">Задачи</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="p-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-colors shadow-lg shadow-primary-500/30"
|
||||||
|
>
|
||||||
|
<Plus size={22} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Фильтры */}
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
{[
|
||||||
|
{ key: 'active', label: 'Активные' },
|
||||||
|
{ key: 'completed', label: 'Выполненные' },
|
||||||
|
{ key: 'all', label: 'Все' },
|
||||||
|
].map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setFilter(key)}
|
||||||
|
className={clsx(
|
||||||
|
'px-4 py-2 rounded-xl text-sm font-medium transition-all',
|
||||||
|
filter === key
|
||||||
|
? 'bg-primary-500 text-white shadow-lg shadow-primary-500/30'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-lg mx-auto px-4 py-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="card p-5 animate-pulse">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gray-200" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-5 bg-gray-200 rounded-lg w-3/4 mb-2" />
|
||||||
|
<div className="h-4 bg-gray-200 rounded-lg w-1/4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="card p-10 text-center"
|
||||||
|
>
|
||||||
|
<div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-primary-100 to-primary-200 flex items-center justify-center mx-auto mb-5">
|
||||||
|
<Check className="w-10 h-10 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-display font-bold text-gray-900 mb-2">
|
||||||
|
{filter === 'active' ? 'Нет активных задач' : filter === 'completed' ? 'Нет выполненных задач' : 'Нет задач'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 mb-6">
|
||||||
|
{filter === 'active' ? 'Добавь новую задачу или выбери другой фильтр' : 'Выполняй задачи и они появятся здесь'}
|
||||||
|
</p>
|
||||||
|
{filter === 'active' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
Добавить задачу
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<AnimatePresence>
|
||||||
|
{tasks.map((task, index) => (
|
||||||
|
<TaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
index={index}
|
||||||
|
onToggle={() => handleToggle(task)}
|
||||||
|
onEdit={() => setEditingTask(task)}
|
||||||
|
isLoading={completeMutation.isPending || uncompleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Navigation />
|
||||||
|
|
||||||
|
<CreateTaskModal
|
||||||
|
open={showCreate}
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditTaskModal
|
||||||
|
open={!!editingTask}
|
||||||
|
onClose={() => setEditingTask(null)}
|
||||||
|
task={editingTask}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskCard({ task, index, onToggle, onEdit, isLoading }) {
|
||||||
|
const [showConfetti, setShowConfetti] = useState(false)
|
||||||
|
const priorityInfo = PRIORITY_LABELS[task.priority]
|
||||||
|
const dueDateLabel = formatDueDate(task.due_date)
|
||||||
|
const isOverdue = task.due_date && isPast(parseISO(task.due_date)) && !isToday(parseISO(task.due_date)) && !task.completed
|
||||||
|
|
||||||
|
const handleCheck = (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (isLoading) return
|
||||||
|
if (!task.completed) {
|
||||||
|
setShowConfetti(true)
|
||||||
|
setTimeout(() => setShowConfetti(false), 1000)
|
||||||
|
}
|
||||||
|
onToggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -100 }}
|
||||||
|
transition={{ delay: index * 0.05 }}
|
||||||
|
className="card p-4 relative overflow-hidden"
|
||||||
|
>
|
||||||
|
{showConfetti && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 1 }}
|
||||||
|
animate={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 1 }}
|
||||||
|
className="absolute inset-0 pointer-events-none"
|
||||||
|
>
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ x: '50%', y: '50%', scale: 0 }}
|
||||||
|
animate={{
|
||||||
|
x: `${Math.random() * 100}%`,
|
||||||
|
y: `${Math.random() * 100}%`,
|
||||||
|
scale: [0, 1, 0]
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.6, delay: i * 0.05 }}
|
||||||
|
className="absolute w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: task.color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<motion.button
|
||||||
|
onClick={handleCheck}
|
||||||
|
disabled={isLoading}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
className={clsx(
|
||||||
|
'w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 flex-shrink-0 mt-0.5',
|
||||||
|
task.completed
|
||||||
|
? 'bg-gradient-to-br from-green-400 to-green-500 shadow-lg shadow-green-400/30'
|
||||||
|
: 'border-2 hover:shadow-md'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
borderColor: task.completed ? undefined : task.color + '40',
|
||||||
|
backgroundColor: task.completed ? undefined : task.color + '10'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{task.completed ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0, rotate: -180 }}
|
||||||
|
animate={{ scale: 1, rotate: 0 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 500 }}
|
||||||
|
>
|
||||||
|
<Check className="w-5 h-5 text-white" strokeWidth={3} />
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<span className="text-lg">{task.icon || '📋'}</span>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0" onClick={onEdit}>
|
||||||
|
<h3 className={clsx(
|
||||||
|
"font-semibold truncate cursor-pointer hover:text-primary-600",
|
||||||
|
task.completed ? "text-gray-400 line-through" : "text-gray-900"
|
||||||
|
)}>{task.title}</h3>
|
||||||
|
|
||||||
|
{task.description && (
|
||||||
|
<p className="text-sm text-gray-500 truncate mt-0.5">{task.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||||
|
{dueDateLabel && (
|
||||||
|
<span className={clsx(
|
||||||
|
'inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium',
|
||||||
|
isOverdue
|
||||||
|
? 'bg-red-100 text-red-700'
|
||||||
|
: 'bg-gray-100 text-gray-600'
|
||||||
|
)}>
|
||||||
|
{isOverdue && <AlertTriangle size={12} />}
|
||||||
|
<Calendar size={12} />
|
||||||
|
{dueDateLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{priorityInfo && (
|
||||||
|
<span className={clsx(
|
||||||
|
'px-2 py-0.5 rounded-md text-xs font-medium',
|
||||||
|
priorityInfo.class
|
||||||
|
)}>
|
||||||
|
{priorityInfo.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={onEdit}
|
||||||
|
className="p-2 text-gray-400 hover:text-primary-500 hover:bg-primary-50 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Edit2 size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{task.completed && (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
onClick={handleCheck}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="p-2 text-gray-400 hover:text-orange-500 hover:bg-orange-50 rounded-xl transition-all"
|
||||||
|
title="Отменить"
|
||||||
|
>
|
||||||
|
<Undo2 size={16} />
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
src/pages/VerifyEmail.jsx
Normal file
103
src/pages/VerifyEmail.jsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useSearchParams, Link } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { CheckCircle, XCircle, Loader2, Zap } from 'lucide-react'
|
||||||
|
import api from '../api/client'
|
||||||
|
|
||||||
|
export default function VerifyEmail() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const [status, setStatus] = useState('loading') // loading, success, error
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
|
||||||
|
const token = searchParams.get('token')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setStatus('error')
|
||||||
|
setMessage('Токен не найден')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const verify = async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/auth/verify-email', { token })
|
||||||
|
setStatus('success')
|
||||||
|
setMessage('Email успешно подтверждён!')
|
||||||
|
} catch (err) {
|
||||||
|
setStatus('error')
|
||||||
|
setMessage(err.response?.data?.error || 'Ошибка верификации')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verify()
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
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 }}
|
||||||
|
className="w-full max-w-md"
|
||||||
|
>
|
||||||
|
<div className="card p-10 text-center">
|
||||||
|
{status === 'loading' && (
|
||||||
|
<>
|
||||||
|
<div className="w-20 h-20 rounded-3xl bg-primary-100 flex items-center justify-center mx-auto mb-6">
|
||||||
|
<Loader2 className="w-10 h-10 text-primary-600 animate-spin" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-display font-bold text-gray-900 mb-2">
|
||||||
|
Проверяем...
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500">Подожди секунду</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'success' && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 200 }}
|
||||||
|
className="w-20 h-20 rounded-3xl bg-green-100 flex items-center justify-center mx-auto mb-6"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-10 h-10 text-green-600" />
|
||||||
|
</motion.div>
|
||||||
|
<h1 className="text-2xl font-display font-bold text-gray-900 mb-2">
|
||||||
|
Готово! 🎉
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mb-6">{message}</p>
|
||||||
|
<Link to="/login" className="btn btn-primary">
|
||||||
|
Войти в аккаунт
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 200 }}
|
||||||
|
className="w-20 h-20 rounded-3xl bg-red-100 flex items-center justify-center mx-auto mb-6"
|
||||||
|
>
|
||||||
|
<XCircle className="w-10 h-10 text-red-600" />
|
||||||
|
</motion.div>
|
||||||
|
<h1 className="text-2xl font-display font-bold text-gray-900 mb-2">
|
||||||
|
Ошибка
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mb-6">{message}</p>
|
||||||
|
<Link to="/login" className="btn btn-secondary">
|
||||||
|
На главную
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-2 mt-6 text-gray-400">
|
||||||
|
<Zap size={16} />
|
||||||
|
<span className="text-sm font-medium">Pulse</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
src/store/auth.js
Normal file
47
src/store/auth.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import api from '../api/client'
|
||||||
|
|
||||||
|
export const useAuthStore = create((set, get) => ({
|
||||||
|
user: null,
|
||||||
|
isLoading: true,
|
||||||
|
isAuthenticated: false,
|
||||||
|
|
||||||
|
initialize: async () => {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
if (!token) {
|
||||||
|
set({ isLoading: false, isAuthenticated: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/auth/me')
|
||||||
|
set({ user: data, isLoading: false, isAuthenticated: true })
|
||||||
|
} catch (error) {
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
localStorage.removeItem('refresh_token')
|
||||||
|
set({ user: null, isLoading: false, isAuthenticated: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
login: async (email, password) => {
|
||||||
|
const { data } = await api.post('/auth/login', { email, password })
|
||||||
|
localStorage.setItem('access_token', data.access_token)
|
||||||
|
localStorage.setItem('refresh_token', data.refresh_token)
|
||||||
|
set({ user: data.user, isAuthenticated: true })
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
register: async (email, username, password) => {
|
||||||
|
const { data } = await api.post('/auth/register', { email, username, password })
|
||||||
|
localStorage.setItem('access_token', data.access_token)
|
||||||
|
localStorage.setItem('refresh_token', data.refresh_token)
|
||||||
|
set({ user: data.user, isAuthenticated: true })
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
localStorage.removeItem('refresh_token')
|
||||||
|
set({ user: null, isAuthenticated: false })
|
||||||
|
},
|
||||||
|
}))
|
||||||
76
tailwind.config.js
Normal file
76
tailwind.config.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
'./index.html',
|
||||||
|
'./src/**/*.{js,ts,jsx,tsx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// Deep Teal - THE color 2026
|
||||||
|
primary: {
|
||||||
|
50: '#f0fdfa',
|
||||||
|
100: '#ccfbf1',
|
||||||
|
200: '#99f6e4',
|
||||||
|
300: '#5eead4',
|
||||||
|
400: '#2dd4bf',
|
||||||
|
500: '#14b8a6',
|
||||||
|
600: '#0d9488',
|
||||||
|
700: '#0f766e',
|
||||||
|
800: '#115e59',
|
||||||
|
900: '#134e4a',
|
||||||
|
950: '#1A535C',
|
||||||
|
},
|
||||||
|
// Burnished Amber - warm motivation
|
||||||
|
accent: {
|
||||||
|
50: '#fffbeb',
|
||||||
|
100: '#fef3c7',
|
||||||
|
200: '#fde68a',
|
||||||
|
300: '#fcd34d',
|
||||||
|
400: '#fbbf24',
|
||||||
|
500: '#F7B538',
|
||||||
|
600: '#d97706',
|
||||||
|
700: '#b45309',
|
||||||
|
800: '#92400e',
|
||||||
|
900: '#78350f',
|
||||||
|
},
|
||||||
|
// Warm backgrounds
|
||||||
|
surface: {
|
||||||
|
50: '#FAF9F6',
|
||||||
|
100: '#f5f5f4',
|
||||||
|
200: '#e7e5e4',
|
||||||
|
800: '#1e293b',
|
||||||
|
900: '#0f172a',
|
||||||
|
950: '#020617',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
display: ['Satoshi', 'Inter', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'check': 'check 0.5s ease-out forwards',
|
||||||
|
'confetti': 'confetti 0.5s ease-out forwards',
|
||||||
|
'pulse-soft': 'pulse-soft 2s ease-in-out infinite',
|
||||||
|
'streak-fire': 'streak-fire 0.6s ease-out forwards',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
check: {
|
||||||
|
'0%': { transform: 'scale(0) rotate(-45deg)', opacity: '0' },
|
||||||
|
'50%': { transform: 'scale(1.2) rotate(0deg)' },
|
||||||
|
'100%': { transform: 'scale(1) rotate(0deg)', opacity: '1' },
|
||||||
|
},
|
||||||
|
'pulse-soft': {
|
||||||
|
'0%, 100%': { opacity: '1' },
|
||||||
|
'50%': { opacity: '0.7' },
|
||||||
|
},
|
||||||
|
'streak-fire': {
|
||||||
|
'0%': { transform: 'scale(0)', opacity: '0' },
|
||||||
|
'50%': { transform: 'scale(1.3)' },
|
||||||
|
'100%': { transform: 'scale(1)', opacity: '1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
10
vite.config.js
Normal file
10
vite.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 3000
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user