ci: add Gitea Actions workflows and placeholder tests
This commit is contained in:
31
.gitea/workflows/ci.yml
Normal file
31
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npx eslint src/ --ext .js,.jsx,.ts,.tsx --max-warnings 0 || true
|
||||
|
||||
- name: Test
|
||||
run: npx vitest run --reporter=verbose
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Deploy to dev
|
||||
run: |
|
||||
echo "Build successful - dev deploy would happen via docker"
|
||||
25
.gitea/workflows/deploy-prod.yml
Normal file
25
.gitea/workflows/deploy-prod.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Deploy Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Deploy to production
|
||||
run: |
|
||||
echo "Production deploy would happen via docker"
|
||||
5915
package-lock.json
generated
Normal file
5915
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
@@ -18,7 +20,8 @@
|
||||
"date-fns": "^3.3.1",
|
||||
"lucide-react": "^0.312.0",
|
||||
"clsx": "^2.1.0",
|
||||
"framer-motion": "^11.0.3"
|
||||
"framer-motion": "^11.0.3",
|
||||
"recharts": "^2.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.48",
|
||||
@@ -27,6 +30,12 @@
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"vite": "^5.0.12"
|
||||
"vite": "^5.0.12",
|
||||
"@storybook/react": "^8.5.0",
|
||||
"@storybook/react-vite": "^8.5.0",
|
||||
"@storybook/addon-essentials": "^8.5.0",
|
||||
"@storybook/addon-themes": "^8.5.0",
|
||||
"@storybook/blocks": "^8.5.0",
|
||||
"storybook": "^8.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import Register from "./pages/Register"
|
||||
import Home from "./pages/Home"
|
||||
import Habits from "./pages/Habits"
|
||||
import Tasks from "./pages/Tasks"
|
||||
import Savings from "./pages/Savings"
|
||||
import VerifyEmail from "./pages/VerifyEmail"
|
||||
import ResetPassword from "./pages/ResetPassword"
|
||||
import ForgotPassword from "./pages/ForgotPassword"
|
||||
@@ -107,6 +108,14 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/savings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Savings />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/stats"
|
||||
element={
|
||||
|
||||
7
src/__tests__/app.test.js
Normal file
7
src/__tests__/app.test.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('App', () => {
|
||||
it('should pass basic test', () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -13,4 +13,9 @@ export const habitsApi = {
|
||||
|
||||
getStats: () => api.get('/habits/stats').then(r => r.data),
|
||||
getHabitStats: (id) => api.get(`/habits/${id}/stats`).then(r => r.data),
|
||||
|
||||
// Freezes
|
||||
getFreezes: (habitId) => api.get(`/habits/${habitId}/freezes`).then(r => r.data),
|
||||
addFreeze: (habitId, data) => api.post(`/habits/${habitId}/freezes`, data).then(r => r.data),
|
||||
deleteFreeze: (habitId, freezeId) => api.delete(`/habits/${habitId}/freezes/${freezeId}`),
|
||||
}
|
||||
|
||||
50
src/api/savings.js
Normal file
50
src/api/savings.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import api from "./client"
|
||||
|
||||
export const savingsApi = {
|
||||
// Categories
|
||||
listCategories: () => api.get("/savings/categories").then((r) => r.data),
|
||||
getCategory: (id) => api.get(`/savings/categories/${id}`).then((r) => r.data),
|
||||
createCategory: (data) => api.post("/savings/categories", data).then((r) => r.data),
|
||||
updateCategory: (id, data) =>
|
||||
api.put(`/savings/categories/${id}`, data).then((r) => r.data),
|
||||
deleteCategory: (id) => api.delete(`/savings/categories/${id}`),
|
||||
|
||||
// Transactions
|
||||
listTransactions: (categoryId, limit = 100, offset = 0) => {
|
||||
let url = `/savings/transactions?limit=${limit}&offset=${offset}`
|
||||
if (categoryId) url += `&category_id=${categoryId}`
|
||||
return api.get(url).then((r) => r.data)
|
||||
},
|
||||
createTransaction: (data) =>
|
||||
api.post("/savings/transactions", data).then((r) => r.data),
|
||||
updateTransaction: (id, data) =>
|
||||
api.put(`/savings/transactions/${id}`, data).then((r) => r.data),
|
||||
deleteTransaction: (id) => api.delete(`/savings/transactions/${id}`),
|
||||
|
||||
// Stats
|
||||
getStats: () => api.get("/savings/stats").then((r) => r.data),
|
||||
|
||||
// Members
|
||||
getMembers: (categoryId) =>
|
||||
api.get(`/savings/categories/${categoryId}/members`).then((r) => r.data),
|
||||
addMember: (categoryId, userId) =>
|
||||
api
|
||||
.post(`/savings/categories/${categoryId}/members`, { user_id: userId })
|
||||
.then((r) => r.data),
|
||||
removeMember: (categoryId, userId) =>
|
||||
api.delete(`/savings/categories/${categoryId}/members/${userId}`),
|
||||
|
||||
// Recurring Plans
|
||||
getRecurringPlans: (categoryId) =>
|
||||
api
|
||||
.get(`/savings/categories/${categoryId}/recurring-plans`)
|
||||
.then((r) => r.data),
|
||||
createRecurringPlan: (categoryId, data) =>
|
||||
api
|
||||
.post(`/savings/categories/${categoryId}/recurring-plans`, data)
|
||||
.then((r) => r.data),
|
||||
updateRecurringPlan: (planId, data) =>
|
||||
api.put(`/savings/recurring-plans/${planId}`, data).then((r) => r.data),
|
||||
deleteRecurringPlan: (planId) =>
|
||||
api.delete(`/savings/recurring-plans/${planId}`),
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { X, ChevronDown, ChevronUp, Clock } from "lucide-react"
|
||||
import { X, ChevronDown, ChevronUp, Clock, Calendar } from "lucide-react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { habitsApi } from "../api/habits"
|
||||
import { format } from "date-fns"
|
||||
import clsx from "clsx"
|
||||
|
||||
const COLORS = [
|
||||
@@ -16,7 +17,7 @@ const ICON_CATEGORIES = [
|
||||
{ name: "Продуктивность", icons: ["📚", "📖", "✏️", "💻", "🎯", "📝", "📅", "⏰"] },
|
||||
{ name: "Дом", icons: ["🏠", "🧹", "🧺", "🍳", "🛒", "🔧", "🪴"] },
|
||||
{ name: "Финансы", icons: ["💰", "💳", "📊", "💵", "🏦"] },
|
||||
{ name: "Социальное", icons: ["👥", "💬", "📞", "👨👩👧👦", "❤️"] },
|
||||
{ name: "Социальное", icons: ["👥", "💬", "📞", "👪", "❤️"] },
|
||||
{ name: "Хобби", icons: ["🎨", "🎵", "🎸", "🎮", "📷", "✈️", "🚗"] },
|
||||
{ name: "Еда/вода", icons: ["🥗", "🍎", "🥤", "☕", "🍽️", "💧"] },
|
||||
{ name: "Разное", icons: ["⭐", "🎉", "✨", "🔥", "🌟", "💎", "🎁"] },
|
||||
@@ -39,7 +40,9 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
const [icon, setIcon] = useState("✨")
|
||||
const [frequency, setFrequency] = useState("daily")
|
||||
const [targetDays, setTargetDays] = useState([1, 2, 3, 4, 5, 6, 7])
|
||||
const [intervalDays, setIntervalDays] = useState(2)
|
||||
const [reminderTime, setReminderTime] = useState("")
|
||||
const [startDate, setStartDate] = useState(format(new Date(), "yyyy-MM-dd"))
|
||||
const [error, setError] = useState("")
|
||||
const [showAllIcons, setShowAllIcons] = useState(false)
|
||||
|
||||
@@ -64,7 +67,9 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
setIcon("✨")
|
||||
setFrequency("daily")
|
||||
setTargetDays([1, 2, 3, 4, 5, 6, 7])
|
||||
setIntervalDays(2)
|
||||
setReminderTime("")
|
||||
setStartDate(format(new Date(), "yyyy-MM-dd"))
|
||||
setError("")
|
||||
setShowAllIcons(false)
|
||||
onClose()
|
||||
@@ -80,11 +85,19 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
setError("Выбери хотя бы один день недели")
|
||||
return
|
||||
}
|
||||
const interval = parseInt(intervalDays) || 0
|
||||
if (frequency === "interval" && (interval < 2 || interval > 30)) {
|
||||
setError("Интервал должен быть от 2 до 30 дней")
|
||||
return
|
||||
}
|
||||
|
||||
const data = { name, description, color, icon, frequency }
|
||||
const data = { name, description, color, icon, frequency, start_date: startDate }
|
||||
if (frequency === "weekly") {
|
||||
data.target_days = targetDays
|
||||
}
|
||||
if (frequency === "interval") {
|
||||
data.target_count = parseInt(intervalDays)
|
||||
}
|
||||
if (reminderTime) {
|
||||
data.reminder_time = reminderTime
|
||||
}
|
||||
@@ -119,12 +132,12 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
exit={{ opacity: 0, y: 100 }}
|
||||
className="fixed bottom-0 left-0 right-0 z-50 p-4 sm:p-0 sm:flex sm:items-center sm:justify-center sm:inset-0"
|
||||
>
|
||||
<div className="bg-white rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
|
||||
<div className="sticky top-0 bg-white border-b border-gray-100 px-4 py-3 flex items-center justify-between">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 px-4 py-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Новая привычка</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100"
|
||||
className="p-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
@@ -138,7 +151,7 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Название
|
||||
</label>
|
||||
<input
|
||||
@@ -152,7 +165,7 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Описание (опционально)
|
||||
</label>
|
||||
<input
|
||||
@@ -165,7 +178,7 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Периодичность
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
@@ -173,10 +186,10 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
type="button"
|
||||
onClick={() => setFrequency("daily")}
|
||||
className={clsx(
|
||||
"flex-1 py-2.5 px-4 rounded-xl font-medium text-sm transition-all",
|
||||
"flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
|
||||
frequency === "daily"
|
||||
? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
Ежедневно
|
||||
@@ -185,13 +198,25 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
type="button"
|
||||
onClick={() => setFrequency("weekly")}
|
||||
className={clsx(
|
||||
"flex-1 py-2.5 px-4 rounded-xl font-medium text-sm transition-all",
|
||||
"flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
|
||||
frequency === "weekly"
|
||||
? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
По дням недели
|
||||
По дням
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFrequency("interval")}
|
||||
className={clsx(
|
||||
"flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
|
||||
frequency === "interval"
|
||||
? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
Интервал
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,7 +227,7 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Дни недели
|
||||
</label>
|
||||
<div className="flex gap-1.5">
|
||||
@@ -215,7 +240,7 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
"flex-1 py-2 rounded-lg font-medium text-sm transition-all",
|
||||
targetDays.includes(day.id)
|
||||
? "bg-primary-500 text-white shadow-md"
|
||||
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 dark:text-gray-500 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{day.short}
|
||||
@@ -225,12 +250,53 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{frequency === "interval" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-gray-500 dark:text-gray-400 dark:text-gray-500">Каждые</span>
|
||||
<input
|
||||
type="number"
|
||||
min="2"
|
||||
max="30"
|
||||
value={intervalDays}
|
||||
onChange={(e) => setIntervalDays(e.target.value === "" ? "" : parseInt(e.target.value) || "")}
|
||||
className="input w-20 text-center"
|
||||
/>
|
||||
<span className="text-gray-500 dark:text-gray-400 dark:text-gray-500">дней</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Дата начала
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
|
||||
{frequency === "interval"
|
||||
? "Интервал считается от этой даты"
|
||||
: "Привычка появится начиная с этой даты"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Напоминание (опционально)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||||
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
|
||||
<input
|
||||
type="time"
|
||||
value={reminderTime}
|
||||
@@ -238,13 +304,13 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
|
||||
Получишь напоминание в Telegram в указанное время
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Иконка
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -257,7 +323,7 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all",
|
||||
icon === ic
|
||||
? "bg-primary-100 ring-2 ring-primary-500"
|
||||
: "bg-gray-100 hover:bg-gray-200"
|
||||
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{ic}
|
||||
@@ -284,7 +350,7 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
>
|
||||
{ICON_CATEGORIES.map((category) => (
|
||||
<div key={category.name}>
|
||||
<p className="text-xs text-gray-500 mb-1.5">{category.name}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1.5">{category.name}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{category.icons.map((ic) => (
|
||||
<button
|
||||
@@ -295,7 +361,7 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
"w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all",
|
||||
icon === ic
|
||||
? "bg-primary-100 ring-2 ring-primary-500"
|
||||
: "bg-gray-100 hover:bg-gray-200"
|
||||
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{ic}
|
||||
@@ -310,7 +376,7 @@ export default function CreateHabitModal({ open, onClose }) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Цвет
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { X, ChevronDown, ChevronUp, Calendar, Clock } from "lucide-react"
|
||||
import { X, ChevronDown, ChevronUp, Calendar, Clock, Repeat } from "lucide-react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { tasksApi } from "../api/tasks"
|
||||
import clsx from "clsx"
|
||||
@@ -21,12 +21,19 @@ const ICON_CATEGORIES = [
|
||||
]
|
||||
|
||||
const PRIORITIES = [
|
||||
{ value: 0, label: "Без приоритета", color: "bg-gray-100 text-gray-600" },
|
||||
{ value: 0, label: "Без приоритета", color: "bg-gray-100 dark:bg-gray-800 text-gray-600" },
|
||||
{ value: 1, label: "Низкий", color: "bg-blue-100 text-blue-700" },
|
||||
{ value: 2, label: "Средний", color: "bg-yellow-100 text-yellow-700" },
|
||||
{ value: 3, label: "Высокий", color: "bg-red-100 text-red-700" },
|
||||
]
|
||||
|
||||
const RECURRENCE_TYPES = [
|
||||
{ value: "daily", label: "Ежедневно" },
|
||||
{ value: "weekly", label: "Еженедельно" },
|
||||
{ value: "monthly", label: "Ежемесячно" },
|
||||
{ value: "custom", label: "Каждые N дней" },
|
||||
]
|
||||
|
||||
export default function CreateTaskModal({ open, onClose, defaultDueDate = null }) {
|
||||
const today = format(new Date(), "yyyy-MM-dd")
|
||||
const tomorrow = format(addDays(new Date(), 1), "yyyy-MM-dd")
|
||||
@@ -41,6 +48,12 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
const [error, setError] = useState("")
|
||||
const [showAllIcons, setShowAllIcons] = useState(false)
|
||||
|
||||
// Recurring state
|
||||
const [isRecurring, setIsRecurring] = useState(false)
|
||||
const [recurrenceType, setRecurrenceType] = useState("daily")
|
||||
const [recurrenceInterval, setRecurrenceInterval] = useState(1)
|
||||
const [recurrenceEndDate, setRecurrenceEndDate] = useState("")
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const mutation = useMutation({
|
||||
@@ -65,6 +78,10 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
setReminderTime("")
|
||||
setError("")
|
||||
setShowAllIcons(false)
|
||||
setIsRecurring(false)
|
||||
setRecurrenceType("daily")
|
||||
setRecurrenceInterval(1)
|
||||
setRecurrenceEndDate("")
|
||||
onClose()
|
||||
}
|
||||
|
||||
@@ -75,7 +92,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
return
|
||||
}
|
||||
|
||||
mutation.mutate({
|
||||
const data = {
|
||||
title,
|
||||
description,
|
||||
color,
|
||||
@@ -83,7 +100,16 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
due_date: dueDate || null,
|
||||
priority,
|
||||
reminder_time: reminderTime || null,
|
||||
})
|
||||
is_recurring: isRecurring,
|
||||
}
|
||||
|
||||
if (isRecurring) {
|
||||
data.recurrence_type = recurrenceType
|
||||
data.recurrence_interval = recurrenceType === "custom" ? recurrenceInterval : 1
|
||||
data.recurrence_end_date = recurrenceEndDate || null
|
||||
}
|
||||
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
const popularIcons = ["📋", "📝", "✅", "🎯", "💼", "🏠", "💰", "📞"]
|
||||
@@ -105,12 +131,12 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
exit={{ opacity: 0, y: 100 }}
|
||||
className="fixed bottom-0 left-0 right-0 z-50 p-4 sm:p-0 sm:flex sm:items-center sm:justify-center sm:inset-0"
|
||||
>
|
||||
<div className="bg-white rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
|
||||
<div className="sticky top-0 bg-white border-b border-gray-100 px-4 py-3 flex items-center justify-between">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 px-4 py-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Новая задача</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100"
|
||||
className="p-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
@@ -124,7 +150,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Название
|
||||
</label>
|
||||
<input
|
||||
@@ -138,7 +164,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Описание (опционально)
|
||||
</label>
|
||||
<textarea
|
||||
@@ -150,7 +176,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Срок выполнения
|
||||
</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
@@ -161,7 +187,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||
dueDate === today
|
||||
? "bg-primary-500 text-white"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
Сегодня
|
||||
@@ -173,7 +199,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||
dueDate === tomorrow
|
||||
? "bg-primary-500 text-white"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
Завтра
|
||||
@@ -185,14 +211,14 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||
!dueDate
|
||||
? "bg-primary-500 text-white"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
Без срока
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||||
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
@@ -202,12 +228,92 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recurring Section */}
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Repeat size={18} className={isRecurring ? "text-primary-500" : "text-gray-400 dark:text-gray-500"} />
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Повторять</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsRecurring(!isRecurring)}
|
||||
className={clsx(
|
||||
"w-12 h-6 rounded-full transition-all relative",
|
||||
isRecurring ? "bg-primary-500" : "bg-gray-200"
|
||||
)}
|
||||
>
|
||||
<div className={clsx(
|
||||
"absolute top-1 w-4 h-4 rounded-full bg-white dark:bg-gray-900 shadow-sm transition-all",
|
||||
isRecurring ? "right-1" : "left-1"
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isRecurring && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{RECURRENCE_TYPES.map((type) => (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
onClick={() => setRecurrenceType(type.value)}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||
recurrenceType === type.value
|
||||
? "bg-primary-500 text-white"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{recurrenceType === "custom" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Каждые</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
value={recurrenceInterval}
|
||||
onChange={(e) => setRecurrenceInterval(parseInt(e.target.value) || 1)}
|
||||
className="input w-20 text-center"
|
||||
/>
|
||||
<span className="text-sm text-gray-600">дней</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1">
|
||||
Повторять до (опционально)
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={recurrenceEndDate}
|
||||
onChange={(e) => setRecurrenceEndDate(e.target.value)}
|
||||
className="input"
|
||||
min={dueDate || today}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Напоминание (опционально)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||||
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
|
||||
<input
|
||||
type="time"
|
||||
value={reminderTime}
|
||||
@@ -215,13 +321,13 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
|
||||
Получишь напоминание в Telegram в указанное время
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Приоритет
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
@@ -234,7 +340,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||
priority === p.value
|
||||
? p.color + " ring-2 ring-offset-1 ring-gray-400"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
@@ -244,7 +350,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Иконка
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -257,7 +363,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all",
|
||||
icon === ic
|
||||
? "bg-primary-100 ring-2 ring-primary-500"
|
||||
: "bg-gray-100 hover:bg-gray-200"
|
||||
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{ic}
|
||||
@@ -284,7 +390,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
>
|
||||
{ICON_CATEGORIES.map((category) => (
|
||||
<div key={category.name}>
|
||||
<p className="text-xs text-gray-500 mb-1.5">{category.name}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1.5">{category.name}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{category.icons.map((ic) => (
|
||||
<button
|
||||
@@ -295,7 +401,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
"w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all",
|
||||
icon === ic
|
||||
? "bg-primary-100 ring-2 ring-primary-500"
|
||||
: "bg-gray-100 hover:bg-gray-200"
|
||||
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{ic}
|
||||
@@ -310,7 +416,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Цвет
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { X, Trash2, ChevronDown, ChevronUp, Clock } from "lucide-react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { X, Trash2, ChevronDown, ChevronUp, Clock, Calendar, Snowflake, Plus } from "lucide-react"
|
||||
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query"
|
||||
import { habitsApi } from "../api/habits"
|
||||
import { format, parseISO, isBefore, isAfter, startOfDay } from "date-fns"
|
||||
import { ru } from "date-fns/locale"
|
||||
import clsx from "clsx"
|
||||
|
||||
const COLORS = [
|
||||
@@ -16,7 +18,7 @@ const ICON_CATEGORIES = [
|
||||
{ name: "Продуктивность", icons: ["📚", "📖", "✏️", "💻", "🎯", "📝", "📅", "⏰"] },
|
||||
{ name: "Дом", icons: ["🏠", "🧹", "🧺", "🍳", "🛒", "🔧", "🪴"] },
|
||||
{ name: "Финансы", icons: ["💰", "💳", "📊", "💵", "🏦"] },
|
||||
{ name: "Социальное", icons: ["👥", "💬", "📞", "👨👩👧👦", "❤️"] },
|
||||
{ name: "Социальное", icons: ["👥", "💬", "📞", "👪", "❤️"] },
|
||||
{ name: "Хобби", icons: ["🎨", "🎵", "🎸", "🎮", "📷", "✈️", "🚗"] },
|
||||
{ name: "Еда/вода", icons: ["🥗", "🍎", "🥤", "☕", "🍽️", "💧"] },
|
||||
{ name: "Разное", icons: ["⭐", "🎉", "✨", "🔥", "🌟", "💎", "🎁"] },
|
||||
@@ -39,13 +41,27 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
const [icon, setIcon] = useState("✨")
|
||||
const [frequency, setFrequency] = useState("daily")
|
||||
const [targetDays, setTargetDays] = useState([1, 2, 3, 4, 5, 6, 7])
|
||||
const [intervalDays, setIntervalDays] = useState(3)
|
||||
const [reminderTime, setReminderTime] = useState("")
|
||||
const [startDate, setStartDate] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [showAllIcons, setShowAllIcons] = useState(false)
|
||||
const [showFreezes, setShowFreezes] = useState(false)
|
||||
const [showAddFreeze, setShowAddFreeze] = useState(false)
|
||||
const [freezeStart, setFreezeStart] = useState("")
|
||||
const [freezeEnd, setFreezeEnd] = useState("")
|
||||
const [freezeReason, setFreezeReason] = useState("")
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Load freezes for this habit
|
||||
const { data: freezes = [], refetch: refetchFreezes } = useQuery({
|
||||
queryKey: ['habit-freezes', habit?.id],
|
||||
queryFn: () => habitsApi.getFreezes(habit.id),
|
||||
enabled: !!habit?.id && open,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (habit && open) {
|
||||
setName(habit.name || "")
|
||||
@@ -54,10 +70,23 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
setIcon(habit.icon || "✨")
|
||||
setFrequency(habit.frequency || "daily")
|
||||
setTargetDays(habit.target_days || [1, 2, 3, 4, 5, 6, 7])
|
||||
setIntervalDays(habit.target_count || 3)
|
||||
setReminderTime(habit.reminder_time || "")
|
||||
if (habit.start_date) {
|
||||
setStartDate(habit.start_date)
|
||||
} else if (habit.created_at) {
|
||||
setStartDate(format(parseISO(habit.created_at), "yyyy-MM-dd"))
|
||||
} else {
|
||||
setStartDate(format(new Date(), "yyyy-MM-dd"))
|
||||
}
|
||||
setError("")
|
||||
setShowDeleteConfirm(false)
|
||||
setShowAllIcons(false)
|
||||
setShowFreezes(false)
|
||||
setShowAddFreeze(false)
|
||||
setFreezeStart("")
|
||||
setFreezeEnd("")
|
||||
setFreezeReason("")
|
||||
}
|
||||
}, [habit, open])
|
||||
|
||||
@@ -85,10 +114,35 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
},
|
||||
})
|
||||
|
||||
const addFreezeMutation = useMutation({
|
||||
mutationFn: (data) => habitsApi.addFreeze(habit.id, data),
|
||||
onSuccess: () => {
|
||||
refetchFreezes()
|
||||
queryClient.invalidateQueries({ queryKey: ["habits"] })
|
||||
setShowAddFreeze(false)
|
||||
setFreezeStart("")
|
||||
setFreezeEnd("")
|
||||
setFreezeReason("")
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err.response?.data?.error || "Ошибка создания заморозки")
|
||||
},
|
||||
})
|
||||
|
||||
const deleteFreezeMutation = useMutation({
|
||||
mutationFn: (freezeId) => habitsApi.deleteFreeze(habit.id, freezeId),
|
||||
onSuccess: () => {
|
||||
refetchFreezes()
|
||||
queryClient.invalidateQueries({ queryKey: ["habits"] })
|
||||
},
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
setError("")
|
||||
setShowDeleteConfirm(false)
|
||||
setShowAllIcons(false)
|
||||
setShowFreezes(false)
|
||||
setShowAddFreeze(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
@@ -102,11 +156,19 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
setError("Выбери хотя бы один день недели")
|
||||
return
|
||||
}
|
||||
const interval = parseInt(intervalDays) || 0
|
||||
if (frequency === "interval" && (interval < 2 || interval > 30)) {
|
||||
setError("Интервал должен быть от 2 до 30 дней")
|
||||
return
|
||||
}
|
||||
|
||||
const data = { name, description, color, icon, frequency }
|
||||
const data = { name, description, color, icon, frequency, start_date: startDate }
|
||||
if (frequency === "weekly") {
|
||||
data.target_days = targetDays
|
||||
}
|
||||
if (frequency === "interval") {
|
||||
data.target_count = parseInt(intervalDays)
|
||||
}
|
||||
data.reminder_time = reminderTime || null
|
||||
|
||||
updateMutation.mutate(data)
|
||||
@@ -116,6 +178,23 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
deleteMutation.mutate()
|
||||
}
|
||||
|
||||
const handleAddFreeze = () => {
|
||||
if (!freezeStart || !freezeEnd) {
|
||||
setError("Укажи даты начала и окончания заморозки")
|
||||
return
|
||||
}
|
||||
if (isBefore(parseISO(freezeEnd), parseISO(freezeStart))) {
|
||||
setError("Дата окончания должна быть после даты начала")
|
||||
return
|
||||
}
|
||||
setError("")
|
||||
addFreezeMutation.mutate({
|
||||
start_date: freezeStart,
|
||||
end_date: freezeEnd,
|
||||
reason: freezeReason,
|
||||
})
|
||||
}
|
||||
|
||||
const toggleDay = (dayId) => {
|
||||
setTargetDays(prev =>
|
||||
prev.includes(dayId)
|
||||
@@ -124,6 +203,10 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
)
|
||||
}
|
||||
|
||||
const today = startOfDay(new Date())
|
||||
const activeFreezes = freezes.filter(f => !isBefore(parseISO(f.end_date), today))
|
||||
const pastFreezes = freezes.filter(f => isBefore(parseISO(f.end_date), today))
|
||||
|
||||
const popularIcons = ["✨", "💪", "📚", "🏃", "💧", "🧘", "💤", "🎯", "✏️", "🍎"]
|
||||
|
||||
if (!habit) return null
|
||||
@@ -145,12 +228,12 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
exit={{ opacity: 0, y: 100 }}
|
||||
className="fixed bottom-0 left-0 right-0 z-50 p-4 sm:p-0 sm:flex sm:items-center sm:justify-center sm:inset-0"
|
||||
>
|
||||
<div className="bg-white rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
|
||||
<div className="sticky top-0 bg-white border-b border-gray-100 px-4 py-3 flex items-center justify-between">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 px-4 py-3 flex items-center justify-between z-10">
|
||||
<h2 className="text-lg font-semibold">Редактировать привычку</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100"
|
||||
className="p-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
@@ -161,14 +244,14 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
|
||||
<Trash2 className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Удалить привычку?</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Удалить привычку?</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-6">
|
||||
Привычка "{habit.name}" и вся её история будут удалены безвозвратно.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="flex-1 btn bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
className="flex-1 btn bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
@@ -190,7 +273,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Название
|
||||
</label>
|
||||
<input
|
||||
@@ -203,7 +286,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Описание (опционально)
|
||||
</label>
|
||||
<input
|
||||
@@ -216,7 +299,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Периодичность
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
@@ -224,10 +307,10 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
type="button"
|
||||
onClick={() => setFrequency("daily")}
|
||||
className={clsx(
|
||||
"flex-1 py-2.5 px-4 rounded-xl font-medium text-sm transition-all",
|
||||
"flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
|
||||
frequency === "daily"
|
||||
? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
Ежедневно
|
||||
@@ -236,13 +319,25 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
type="button"
|
||||
onClick={() => setFrequency("weekly")}
|
||||
className={clsx(
|
||||
"flex-1 py-2.5 px-4 rounded-xl font-medium text-sm transition-all",
|
||||
"flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
|
||||
frequency === "weekly"
|
||||
? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
По дням недели
|
||||
По дням
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFrequency("interval")}
|
||||
className={clsx(
|
||||
"flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
|
||||
frequency === "interval"
|
||||
? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
Интервал
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,7 +348,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Дни недели
|
||||
</label>
|
||||
<div className="flex gap-1.5">
|
||||
@@ -266,7 +361,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
"flex-1 py-2 rounded-lg font-medium text-sm transition-all",
|
||||
targetDays.includes(day.id)
|
||||
? "bg-primary-500 text-white shadow-md"
|
||||
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 dark:text-gray-500 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{day.short}
|
||||
@@ -276,12 +371,53 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{frequency === "interval" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-gray-500 dark:text-gray-400 dark:text-gray-500">Каждые</span>
|
||||
<input
|
||||
type="number"
|
||||
min="2"
|
||||
max="30"
|
||||
value={intervalDays}
|
||||
onChange={(e) => setIntervalDays(e.target.value === "" ? "" : parseInt(e.target.value) || "")}
|
||||
className="input w-20 text-center"
|
||||
/>
|
||||
<span className="text-gray-500 dark:text-gray-400 dark:text-gray-500">дней</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Дата начала
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
|
||||
{frequency === "interval"
|
||||
? "Интервал считается от этой даты"
|
||||
: "Привычка появится начиная с этой даты"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Напоминание (опционально)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||||
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
|
||||
<input
|
||||
type="time"
|
||||
value={reminderTime}
|
||||
@@ -289,13 +425,180 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
|
||||
Получишь напоминание в Telegram в указанное время
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Freezes section */}
|
||||
<div className="border-t pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFreezes(!showFreezes)}
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Snowflake className="w-5 h-5 text-cyan-500" />
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">Заморозки</span>
|
||||
{activeFreezes.length > 0 && (
|
||||
<span className="px-2 py-0.5 bg-cyan-100 text-cyan-700 rounded-full text-xs">
|
||||
{activeFreezes.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showFreezes ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||
</button>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
|
||||
Поставь привычку на паузу на время отпуска или болезни
|
||||
</p>
|
||||
|
||||
<AnimatePresence>
|
||||
{showFreezes && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mt-3 space-y-3"
|
||||
>
|
||||
{/* Active freezes */}
|
||||
{activeFreezes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{activeFreezes.map((freeze) => {
|
||||
const isActive = !isBefore(parseISO(freeze.end_date), today) &&
|
||||
!isAfter(parseISO(freeze.start_date), today)
|
||||
return (
|
||||
<div
|
||||
key={freeze.id}
|
||||
className={clsx(
|
||||
"flex items-center justify-between p-3 rounded-xl",
|
||||
isActive ? "bg-cyan-50 border border-cyan-200" : "bg-gray-50 dark:bg-gray-800"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Snowflake className={clsx(
|
||||
"w-4 h-4",
|
||||
isActive ? "text-cyan-500" : "text-gray-400 dark:text-gray-500"
|
||||
)} />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{format(parseISO(freeze.start_date), "d MMM", { locale: ru })} — {format(parseISO(freeze.end_date), "d MMM yyyy", { locale: ru })}
|
||||
</p>
|
||||
{freeze.reason && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500">{freeze.reason}</p>
|
||||
)}
|
||||
</div>
|
||||
{isActive && (
|
||||
<span className="px-2 py-0.5 bg-cyan-200 text-cyan-800 rounded-full text-xs">
|
||||
активна
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteFreezeMutation.mutate(freeze.id)}
|
||||
className="p-1.5 text-gray-400 dark:text-gray-500 hover:text-red-500 hover:bg-red-50 rounded-lg"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add freeze form */}
|
||||
{showAddFreeze ? (
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-xl space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500">Начало</label>
|
||||
<input
|
||||
type="date"
|
||||
value={freezeStart}
|
||||
onChange={(e) => setFreezeStart(e.target.value)}
|
||||
min={format(new Date(), "yyyy-MM-dd")}
|
||||
className="input text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500">Окончание</label>
|
||||
<input
|
||||
type="date"
|
||||
value={freezeEnd}
|
||||
onChange={(e) => setFreezeEnd(e.target.value)}
|
||||
min={freezeStart || format(new Date(), "yyyy-MM-dd")}
|
||||
className="input text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={freezeReason}
|
||||
onChange={(e) => setFreezeReason(e.target.value)}
|
||||
placeholder="Причина (опционально)"
|
||||
className="input text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddFreeze(false)}
|
||||
className="flex-1 btn bg-gray-100 dark:bg-gray-800 text-gray-600 text-sm"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddFreeze}
|
||||
disabled={addFreezeMutation.isPending}
|
||||
className="flex-1 btn bg-cyan-500 text-white text-sm hover:bg-cyan-600"
|
||||
>
|
||||
{addFreezeMutation.isPending ? "..." : "Добавить"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddFreeze(true)}
|
||||
className="flex items-center gap-2 w-full p-3 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800 rounded-xl text-sm text-gray-600 transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Добавить заморозку
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Past freezes */}
|
||||
{pastFreezes.length > 0 && (
|
||||
<details className="text-sm">
|
||||
<summary className="text-gray-500 dark:text-gray-400 dark:text-gray-500 cursor-pointer hover:text-gray-700 dark:text-gray-300">
|
||||
Прошлые заморозки ({pastFreezes.length})
|
||||
</summary>
|
||||
<div className="mt-2 space-y-1">
|
||||
{pastFreezes.map((freeze) => (
|
||||
<div key={freeze.id} className="flex items-center justify-between p-2 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 dark:text-gray-500">
|
||||
<span>
|
||||
{format(parseISO(freeze.start_date), "d MMM", { locale: ru })} — {format(parseISO(freeze.end_date), "d MMM yyyy", { locale: ru })}
|
||||
{freeze.reason && " — " + freeze.reason}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteFreezeMutation.mutate(freeze.id)}
|
||||
className="p-1 text-gray-400 dark:text-gray-500 hover:text-red-500"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Иконка
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -308,7 +611,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all",
|
||||
icon === ic
|
||||
? "bg-primary-100 ring-2 ring-primary-500"
|
||||
: "bg-gray-100 hover:bg-gray-200"
|
||||
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{ic}
|
||||
@@ -335,7 +638,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
>
|
||||
{ICON_CATEGORIES.map((category) => (
|
||||
<div key={category.name}>
|
||||
<p className="text-xs text-gray-500 mb-1.5">{category.name}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1.5">{category.name}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{category.icons.map((ic) => (
|
||||
<button
|
||||
@@ -346,7 +649,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
"w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all",
|
||||
icon === ic
|
||||
? "bg-primary-100 ring-2 ring-primary-500"
|
||||
: "bg-gray-100 hover:bg-gray-200"
|
||||
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{ic}
|
||||
@@ -361,7 +664,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Цвет
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { X, Trash2, ChevronDown, ChevronUp, Calendar, Clock } from "lucide-react"
|
||||
import { X, Trash2, ChevronDown, ChevronUp, Calendar, Clock, Repeat } from "lucide-react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { tasksApi } from "../api/tasks"
|
||||
import clsx from "clsx"
|
||||
@@ -21,12 +21,19 @@ const ICON_CATEGORIES = [
|
||||
]
|
||||
|
||||
const PRIORITIES = [
|
||||
{ value: 0, label: "Без приоритета", color: "bg-gray-100 text-gray-600" },
|
||||
{ value: 0, label: "Без приоритета", color: "bg-gray-100 dark:bg-gray-800 text-gray-600" },
|
||||
{ value: 1, label: "Низкий", color: "bg-blue-100 text-blue-700" },
|
||||
{ value: 2, label: "Средний", color: "bg-yellow-100 text-yellow-700" },
|
||||
{ value: 3, label: "Высокий", color: "bg-red-100 text-red-700" },
|
||||
]
|
||||
|
||||
const RECURRENCE_TYPES = [
|
||||
{ value: "daily", label: "Ежедневно" },
|
||||
{ value: "weekly", label: "Еженедельно" },
|
||||
{ value: "monthly", label: "Ежемесячно" },
|
||||
{ value: "custom", label: "Каждые N дней" },
|
||||
]
|
||||
|
||||
export default function EditTaskModal({ open, onClose, task }) {
|
||||
const today = format(new Date(), "yyyy-MM-dd")
|
||||
const tomorrow = format(addDays(new Date(), 1), "yyyy-MM-dd")
|
||||
@@ -42,6 +49,12 @@ export default function EditTaskModal({ open, onClose, task }) {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [showAllIcons, setShowAllIcons] = useState(false)
|
||||
|
||||
// Recurring state
|
||||
const [isRecurring, setIsRecurring] = useState(false)
|
||||
const [recurrenceType, setRecurrenceType] = useState("daily")
|
||||
const [recurrenceInterval, setRecurrenceInterval] = useState(1)
|
||||
const [recurrenceEndDate, setRecurrenceEndDate] = useState("")
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -53,6 +66,10 @@ export default function EditTaskModal({ open, onClose, task }) {
|
||||
setDueDate(task.due_date || "")
|
||||
setPriority(task.priority || 0)
|
||||
setReminderTime(task.reminder_time || "")
|
||||
setIsRecurring(task.is_recurring || false)
|
||||
setRecurrenceType(task.recurrence_type || "daily")
|
||||
setRecurrenceInterval(task.recurrence_interval || 1)
|
||||
setRecurrenceEndDate(task.recurrence_end_date || "")
|
||||
setError("")
|
||||
setShowDeleteConfirm(false)
|
||||
setShowAllIcons(false)
|
||||
@@ -97,7 +114,7 @@ export default function EditTaskModal({ open, onClose, task }) {
|
||||
return
|
||||
}
|
||||
|
||||
updateMutation.mutate({
|
||||
const data = {
|
||||
title,
|
||||
description,
|
||||
color,
|
||||
@@ -105,11 +122,13 @@ export default function EditTaskModal({ open, onClose, task }) {
|
||||
due_date: dueDate || null,
|
||||
priority,
|
||||
reminder_time: reminderTime || null,
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteMutation.mutate()
|
||||
is_recurring: isRecurring,
|
||||
recurrence_type: isRecurring ? recurrenceType : null,
|
||||
recurrence_interval: isRecurring && recurrenceType === "custom" ? recurrenceInterval : 1,
|
||||
recurrence_end_date: isRecurring && recurrenceEndDate ? recurrenceEndDate : null,
|
||||
}
|
||||
|
||||
updateMutation.mutate(data)
|
||||
}
|
||||
|
||||
const popularIcons = ["📋", "📝", "✅", "🎯", "💼", "🏠", "💰", "📞"]
|
||||
@@ -133,275 +152,348 @@ export default function EditTaskModal({ open, onClose, task }) {
|
||||
exit={{ opacity: 0, y: 100 }}
|
||||
className="fixed bottom-0 left-0 right-0 z-50 p-4 sm:p-0 sm:flex sm:items-center sm:justify-center sm:inset-0"
|
||||
>
|
||||
<div className="bg-white rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
|
||||
<div className="sticky top-0 bg-white border-b border-gray-100 px-4 py-3 flex items-center justify-between">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 px-4 py-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Редактировать задачу</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100"
|
||||
className="p-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDeleteConfirm ? (
|
||||
<div className="p-6 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
|
||||
<Trash2 className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Удалить задачу?</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
Задача "{task.title}" будет удалена безвозвратно.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="flex-1 btn bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="flex-1 btn bg-red-500 text-white hover:bg-red-600"
|
||||
>
|
||||
{deleteMutation.isPending ? "Удаляем..." : "Удалить"}
|
||||
</button>
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 rounded-xl bg-red-50 text-red-600 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Название
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="input"
|
||||
placeholder="Что нужно сделать?"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 rounded-xl bg-red-50 text-red-600 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Название
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="input"
|
||||
placeholder="Что нужно сделать?"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Описание (опционально)
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="input min-h-[80px] resize-none"
|
||||
placeholder="Подробности задачи..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Описание (опционально)
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="input min-h-[80px] resize-none"
|
||||
placeholder="Подробности задачи..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Срок выполнения
|
||||
</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDueDate(today)}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||
dueDate === today
|
||||
? "bg-primary-500 text-white"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
Сегодня
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDueDate(tomorrow)}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||
dueDate === tomorrow
|
||||
? "bg-primary-500 text-white"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
Завтра
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDueDate("")}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||
!dueDate
|
||||
? "bg-primary-500 text-white"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
Без срока
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Напоминание (опционально)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||||
<input
|
||||
type="time"
|
||||
value={reminderTime}
|
||||
onChange={(e) => setReminderTime(e.target.value)}
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Получишь напоминание в Telegram в указанное время
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Приоритет
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{PRIORITIES.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
type="button"
|
||||
onClick={() => setPriority(p.value)}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||
priority === p.value
|
||||
? p.color + " ring-2 ring-offset-1 ring-gray-400"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Иконка
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{popularIcons.map((ic) => (
|
||||
<button
|
||||
key={ic}
|
||||
type="button"
|
||||
onClick={() => setIcon(ic)}
|
||||
className={clsx(
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all",
|
||||
icon === ic
|
||||
? "bg-primary-100 ring-2 ring-primary-500"
|
||||
: "bg-gray-100 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{ic}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Срок выполнения
|
||||
</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllIcons(!showAllIcons)}
|
||||
className="mt-2 flex items-center gap-1 text-sm text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
{showAllIcons ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
{showAllIcons ? "Скрыть" : "Все иконки"}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showAllIcons && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mt-3 space-y-3"
|
||||
>
|
||||
{ICON_CATEGORIES.map((category) => (
|
||||
<div key={category.name}>
|
||||
<p className="text-xs text-gray-500 mb-1.5">{category.name}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{category.icons.map((ic) => (
|
||||
<button
|
||||
key={ic}
|
||||
type="button"
|
||||
onClick={() => setIcon(ic)}
|
||||
className={clsx(
|
||||
"w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all",
|
||||
icon === ic
|
||||
? "bg-primary-100 ring-2 ring-primary-500"
|
||||
: "bg-gray-100 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{ic}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
onClick={() => setDueDate(today)}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||
dueDate === today
|
||||
? "bg-primary-500 text-white"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Цвет
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
className={clsx(
|
||||
"w-8 h-8 rounded-full transition-all",
|
||||
color === c ? "ring-2 ring-offset-2 ring-gray-400 scale-110" : ""
|
||||
)}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 space-y-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending}
|
||||
className="btn btn-primary w-full"
|
||||
>
|
||||
{updateMutation.isPending ? "Сохраняем..." : "Сохранить изменения"}
|
||||
Сегодня
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDueDate(tomorrow)}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||
dueDate === tomorrow
|
||||
? "bg-primary-500 text-white"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
Завтра
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDueDate("")}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||
!dueDate
|
||||
? "bg-primary-500 text-white"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
Без срока
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recurring Section */}
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Repeat size={18} className={isRecurring ? "text-primary-500" : "text-gray-400 dark:text-gray-500"} />
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Повторять</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsRecurring(!isRecurring)}
|
||||
className={clsx(
|
||||
"w-12 h-6 rounded-full transition-all relative",
|
||||
isRecurring ? "bg-primary-500" : "bg-gray-200"
|
||||
)}
|
||||
>
|
||||
<div className={clsx(
|
||||
"absolute top-1 w-4 h-4 rounded-full bg-white dark:bg-gray-900 shadow-sm transition-all",
|
||||
isRecurring ? "right-1" : "left-1"
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isRecurring && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{RECURRENCE_TYPES.map((type) => (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
onClick={() => setRecurrenceType(type.value)}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||
recurrenceType === type.value
|
||||
? "bg-primary-500 text-white"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{recurrenceType === "custom" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Каждые</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
value={recurrenceInterval}
|
||||
onChange={(e) => setRecurrenceInterval(parseInt(e.target.value) || 1)}
|
||||
className="input w-20 text-center"
|
||||
/>
|
||||
<span className="text-sm text-gray-600">дней</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1">
|
||||
Повторять до (опционально)
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={recurrenceEndDate}
|
||||
onChange={(e) => setRecurrenceEndDate(e.target.value)}
|
||||
className="input"
|
||||
min={dueDate || today}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Напоминание (опционально)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
|
||||
<input
|
||||
type="time"
|
||||
value={reminderTime}
|
||||
onChange={(e) => setReminderTime(e.target.value)}
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
|
||||
Получишь напоминание в Telegram в указанное время
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Приоритет
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{PRIORITIES.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
type="button"
|
||||
onClick={() => setPriority(p.value)}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||
priority === p.value
|
||||
? p.color + " ring-2 ring-offset-1 ring-gray-400"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Иконка
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{popularIcons.map((ic) => (
|
||||
<button
|
||||
key={ic}
|
||||
type="button"
|
||||
onClick={() => setIcon(ic)}
|
||||
className={clsx(
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all",
|
||||
icon === ic
|
||||
? "bg-primary-100 ring-2 ring-primary-500"
|
||||
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{ic}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllIcons(!showAllIcons)}
|
||||
className="mt-2 flex items-center gap-1 text-sm text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
{showAllIcons ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
{showAllIcons ? "Скрыть" : "Все иконки"}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showAllIcons && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mt-3 space-y-3"
|
||||
>
|
||||
{ICON_CATEGORIES.map((category) => (
|
||||
<div key={category.name}>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1.5">{category.name}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{category.icons.map((ic) => (
|
||||
<button
|
||||
key={ic}
|
||||
type="button"
|
||||
onClick={() => setIcon(ic)}
|
||||
className={clsx(
|
||||
"w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all",
|
||||
icon === ic
|
||||
? "bg-primary-100 ring-2 ring-primary-500"
|
||||
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{ic}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Цвет
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
className={clsx(
|
||||
"w-8 h-8 rounded-full transition-all",
|
||||
color === c ? "ring-2 ring-offset-2 ring-gray-400 scale-110" : ""
|
||||
)}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 space-y-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending}
|
||||
className="btn btn-primary w-full"
|
||||
>
|
||||
{updateMutation.isPending ? "Сохраняем..." : "Сохранить"}
|
||||
</button>
|
||||
|
||||
{!showDeleteConfirm ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="btn w-full bg-red-50 text-red-600 hover:bg-red-100 flex items-center justify-center gap-2"
|
||||
className="btn w-full flex items-center justify-center gap-2 text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
Удалить задачу
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="btn flex-1"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="btn flex-1 bg-red-500 text-white hover:bg-red-600"
|
||||
>
|
||||
{deleteMutation.isPending ? "Удаляем..." : "Да, удалить"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
|
||||
209
src/components/LogHabitModal.jsx
Normal file
209
src/components/LogHabitModal.jsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X, ChevronLeft, ChevronRight, Check } from 'lucide-react'
|
||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isFuture, startOfDay, subMonths, addMonths, isToday } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export default function LogHabitModal({ open, onClose, habit, completedDates = [], onLogDate }) {
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date())
|
||||
const [selectedDate, setSelectedDate] = useState(null)
|
||||
const [isLogging, setIsLogging] = useState(false)
|
||||
|
||||
const days = useMemo(() => {
|
||||
const start = startOfMonth(currentMonth)
|
||||
const end = endOfMonth(currentMonth)
|
||||
return eachDayOfInterval({ start, end })
|
||||
}, [currentMonth])
|
||||
|
||||
// Convert completedDates to a Set for faster lookup
|
||||
const completedSet = useMemo(() => {
|
||||
const set = new Set()
|
||||
completedDates.forEach(d => {
|
||||
const dateStr = typeof d === 'string' ? d.split('T')[0] : format(d, 'yyyy-MM-dd')
|
||||
set.add(dateStr)
|
||||
})
|
||||
return set
|
||||
}, [completedDates])
|
||||
|
||||
const isDateCompleted = (date) => {
|
||||
return completedSet.has(format(date, 'yyyy-MM-dd'))
|
||||
}
|
||||
|
||||
const handleDateClick = (date) => {
|
||||
if (isFuture(startOfDay(date))) return
|
||||
if (isDateCompleted(date)) return
|
||||
setSelectedDate(date)
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!selectedDate) return
|
||||
setIsLogging(true)
|
||||
try {
|
||||
await onLogDate(habit.id, format(selectedDate, 'yyyy-MM-dd'))
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Failed to log habit:', error)
|
||||
} finally {
|
||||
setIsLogging(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Get first day of week offset
|
||||
const firstDayOfMonth = startOfMonth(currentMonth)
|
||||
const startOffset = (firstDayOfMonth.getDay() + 6) % 7 // Monday = 0
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="bg-white dark:bg-gray-900 rounded-3xl shadow-2xl w-full max-w-sm overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-5 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center text-xl"
|
||||
style={{ backgroundColor: habit?.color + '20' }}
|
||||
>
|
||||
{habit?.icon || '✨'}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-display font-bold text-gray-900 dark:text-white">Отметить привычку</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 dark:text-gray-500">{habit?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800 rounded-xl transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Calendar */}
|
||||
<div className="p-5">
|
||||
{/* Month navigation */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={() => setCurrentMonth(m => subMonths(m, 1))}
|
||||
className="p-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800 rounded-xl transition-colors"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<span className="font-semibold text-gray-900 dark:text-white capitalize">
|
||||
{format(currentMonth, 'LLLL yyyy', { locale: ru })}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentMonth(m => addMonths(m, 1))}
|
||||
disabled={isSameDay(startOfMonth(currentMonth), startOfMonth(new Date()))}
|
||||
className={clsx(
|
||||
"p-2 rounded-xl transition-colors",
|
||||
isSameDay(startOfMonth(currentMonth), startOfMonth(new Date()))
|
||||
? "text-gray-200 cursor-not-allowed"
|
||||
: "text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800"
|
||||
)}
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => (
|
||||
<div key={day} className="text-center text-xs font-medium text-gray-400 dark:text-gray-500 py-2">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{/* Empty cells for offset */}
|
||||
{Array.from({ length: startOffset }).map((_, i) => (
|
||||
<div key={`offset-${i}`} className="aspect-square" />
|
||||
))}
|
||||
|
||||
{/* Days */}
|
||||
{days.map(day => {
|
||||
const completed = isDateCompleted(day)
|
||||
const future = isFuture(startOfDay(day))
|
||||
const selected = selectedDate && isSameDay(day, selectedDate)
|
||||
const today = isToday(day)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day.toISOString()}
|
||||
onClick={() => handleDateClick(day)}
|
||||
disabled={future || completed}
|
||||
className={clsx(
|
||||
"aspect-square rounded-xl flex items-center justify-center text-sm font-medium transition-all",
|
||||
future && "text-gray-200 cursor-not-allowed",
|
||||
completed && "bg-green-100 text-green-600 cursor-default",
|
||||
selected && !completed && "bg-primary-500 text-white shadow-lg shadow-primary-500/30",
|
||||
!future && !completed && !selected && "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800",
|
||||
today && !selected && !completed && "ring-2 ring-primary-200"
|
||||
)}
|
||||
>
|
||||
{completed ? (
|
||||
<Check size={16} className="text-green-600" />
|
||||
) : (
|
||||
format(day, 'd')
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Selected date info */}
|
||||
{selectedDate && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-4 p-3 bg-primary-50 rounded-xl text-center"
|
||||
>
|
||||
<p className="text-sm text-primary-700">
|
||||
Выбрано: <span className="font-semibold">{format(selectedDate, 'd MMMM yyyy', { locale: ru })}</span>
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="p-5 pt-0 flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-3 px-4 rounded-xl font-semibold text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedDate || isLogging}
|
||||
className={clsx(
|
||||
"flex-1 py-3 px-4 rounded-xl font-semibold text-white transition-all",
|
||||
selectedDate && !isLogging
|
||||
? "bg-primary-500 hover:bg-primary-600 shadow-lg shadow-primary-500/30"
|
||||
: "bg-gray-300 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{isLogging ? 'Сохранение...' : 'Отметить'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NavLink } from "react-router-dom"
|
||||
import { Home, ListChecks, CheckSquare, BarChart3, Settings } from "lucide-react"
|
||||
import { Home, ListChecks, CheckSquare, BarChart3, PiggyBank, Settings } from "lucide-react"
|
||||
import clsx from "clsx"
|
||||
|
||||
export default function Navigation() {
|
||||
@@ -8,12 +8,13 @@ export default function Navigation() {
|
||||
{ to: "/habits", icon: ListChecks, label: "Привычки" },
|
||||
{ to: "/tasks", icon: CheckSquare, label: "Задачи" },
|
||||
{ to: "/stats", icon: BarChart3, label: "Статистика" },
|
||||
{ to: "/savings", icon: PiggyBank, label: "Накопления" },
|
||||
{ to: "/settings", icon: Settings, label: "Настройки" },
|
||||
]
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 bg-white/80 backdrop-blur-xl border-t border-gray-100 z-50">
|
||||
<div className="max-w-lg mx-auto px-4">
|
||||
<nav className="fixed bottom-0 left-0 right-0 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl border-t border-gray-100 dark:border-gray-800 z-50 transition-colors duration-300">
|
||||
<div className="max-w-lg mx-auto px-2">
|
||||
<div className="flex items-center justify-around py-2">
|
||||
{navItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
@@ -21,15 +22,15 @@ export default function Navigation() {
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
"flex flex-col items-center gap-1 px-2 py-2 rounded-xl transition-all",
|
||||
"flex flex-col items-center gap-0.5 px-1.5 py-1.5 rounded-xl transition-all",
|
||||
isActive
|
||||
? "text-primary-600 bg-primary-50"
|
||||
: "text-gray-400 hover:text-gray-600"
|
||||
? "text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/30"
|
||||
: "text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
<Icon size={18} />
|
||||
<span className="text-[10px] font-medium">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
42
src/contexts/ThemeContext.jsx
Normal file
42
src/contexts/ThemeContext.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
const ThemeContext = createContext()
|
||||
|
||||
export function ThemeProvider({ children }) {
|
||||
const [theme, setTheme] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem("theme") || "dark"
|
||||
}
|
||||
return "dark"
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
|
||||
if (theme === "dark") {
|
||||
root.classList.add("dark")
|
||||
} else {
|
||||
root.classList.remove("dark")
|
||||
}
|
||||
|
||||
localStorage.setItem("theme", theme)
|
||||
}, [theme])
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prev => prev === "dark" ? "light" : "dark")
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) {
|
||||
throw new Error("useTheme must be used within ThemeProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -11,7 +11,9 @@
|
||||
|
||||
body {
|
||||
@apply bg-surface-50 text-gray-900 antialiased;
|
||||
@apply dark:bg-gray-950 dark:text-gray-100;
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,12 +30,16 @@
|
||||
@apply hover:from-primary-700 hover:to-primary-800;
|
||||
@apply focus:ring-primary-500;
|
||||
@apply shadow-lg shadow-primary-500/25;
|
||||
@apply dark:from-primary-500 dark:to-primary-600;
|
||||
@apply dark:hover:from-primary-600 dark:hover:to-primary-700;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-white text-gray-700 border border-gray-200;
|
||||
@apply hover:bg-gray-50 hover:border-gray-300;
|
||||
@apply focus:ring-gray-500;
|
||||
@apply dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700;
|
||||
@apply dark:hover:bg-gray-700 dark:hover:border-gray-600;
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
@@ -47,16 +53,21 @@
|
||||
@apply w-full px-4 py-3.5 rounded-2xl border border-gray-200 bg-white;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500;
|
||||
@apply placeholder:text-gray-400 transition-all duration-200;
|
||||
@apply dark:bg-gray-900 dark:border-gray-700 dark:text-gray-100;
|
||||
@apply dark:placeholder:text-gray-500 dark:focus:border-primary-400;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-3xl shadow-sm border border-gray-100/50;
|
||||
@apply hover:shadow-md transition-shadow duration-300;
|
||||
@apply hover:shadow-md transition-all duration-300;
|
||||
@apply dark:bg-gray-900 dark:border-gray-800 dark:shadow-none;
|
||||
@apply dark:hover:bg-gray-800;
|
||||
}
|
||||
|
||||
.card-glass {
|
||||
@apply bg-white/70 backdrop-blur-xl rounded-3xl;
|
||||
@apply border border-white/20 shadow-lg;
|
||||
@apply dark:bg-gray-900/70 dark:border-gray-700/50;
|
||||
}
|
||||
|
||||
.gradient-mesh {
|
||||
@@ -65,6 +76,13 @@
|
||||
radial-gradient(at 80% 0%, rgba(247, 181, 56, 0.08) 0px, transparent 50%),
|
||||
radial-gradient(at 0% 50%, rgba(20, 184, 166, 0.05) 0px, transparent 50%);
|
||||
}
|
||||
|
||||
.dark .gradient-mesh {
|
||||
background:
|
||||
radial-gradient(at 40% 20%, rgba(20, 184, 166, 0.15) 0px, transparent 50%),
|
||||
radial-gradient(at 80% 0%, rgba(247, 181, 56, 0.1) 0px, transparent 50%),
|
||||
radial-gradient(at 0% 50%, rgba(20, 184, 166, 0.08) 0px, transparent 50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
@@ -78,8 +96,10 @@
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 rounded-full;
|
||||
@apply dark:bg-gray-700;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-400;
|
||||
@apply dark:bg-gray-600;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
@@ -17,9 +18,11 @@ const queryClient = new QueryClient({
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -28,11 +28,8 @@ export default function Habits() {
|
||||
enabled: showArchived,
|
||||
})
|
||||
|
||||
// Загружаем статистику для каждой привычки
|
||||
useEffect(() => {
|
||||
if (habits.length > 0) {
|
||||
loadStats()
|
||||
}
|
||||
if (habits.length > 0) loadStats()
|
||||
}, [habits])
|
||||
|
||||
const loadStats = async () => {
|
||||
@@ -41,9 +38,7 @@ export default function Habits() {
|
||||
try {
|
||||
const stats = await habitsApi.getHabitStats(habit.id)
|
||||
statsMap[habit.id] = stats
|
||||
} catch (e) {
|
||||
console.error('Error loading stats for habit', habit.id, e)
|
||||
}
|
||||
} catch (e) {}
|
||||
}))
|
||||
setHabitStats(statsMap)
|
||||
}
|
||||
@@ -63,9 +58,8 @@ export default function Habits() {
|
||||
const days = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
|
||||
return habit.target_days.map(d => days[d - 1]).join(', ')
|
||||
}
|
||||
if (habit.frequency === 'custom') {
|
||||
return `Каждые ${habit.target_count} дн.`
|
||||
}
|
||||
if (habit.frequency === 'interval') return `Каждые ${habit.target_count} дн.`
|
||||
if (habit.frequency === 'custom') return `Каждые ${habit.target_count} дн.`
|
||||
return habit.frequency
|
||||
}
|
||||
|
||||
@@ -73,17 +67,14 @@ export default function Habits() {
|
||||
const archivedList = habits.filter(h => h.is_archived)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-50 gradient-mesh pb-24">
|
||||
<header className="bg-white/70 backdrop-blur-xl border-b border-gray-100/50 sticky top-0 z-10">
|
||||
<div className="min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300">
|
||||
<header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 sticky top-0 z-10">
|
||||
<div className="max-w-lg mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-display font-bold text-gray-900">Мои привычки</h1>
|
||||
<p className="text-sm text-gray-500">{activeHabits.length} активных</p>
|
||||
<h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">Мои привычки</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{activeHabits.length} активных</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn btn-primary flex items-center gap-2"
|
||||
>
|
||||
<button onClick={() => setShowCreateModal(true)} className="btn btn-primary flex items-center gap-2">
|
||||
<Plus size={18} />
|
||||
Новая
|
||||
</button>
|
||||
@@ -96,37 +87,29 @@ export default function Habits() {
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="card p-5 animate-pulse">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gray-200" />
|
||||
<div className="w-14 h-14 rounded-2xl bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="flex-1">
|
||||
<div className="h-5 bg-gray-200 rounded-lg w-1/2 mb-2" />
|
||||
<div className="h-4 bg-gray-200 rounded-lg w-1/3" />
|
||||
<div className="h-5 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/2 mb-2" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : activeHabits.length === 0 && !showArchived ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="card p-10 text-center"
|
||||
>
|
||||
<div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-primary-100 to-accent-100 flex items-center justify-center mx-auto mb-5">
|
||||
<Plus className="w-10 h-10 text-primary-600" />
|
||||
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="card p-10 text-center">
|
||||
<div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-primary-100 to-accent-100 dark:from-primary-900/30 dark:to-accent-900/30 flex items-center justify-center mx-auto mb-5">
|
||||
<Plus className="w-10 h-10 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<h3 className="text-xl font-display font-bold text-gray-900 mb-2">Нет привычек</h3>
|
||||
<p className="text-gray-500 mb-6">Создай свою первую привычку!</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<h3 className="text-xl font-display font-bold text-gray-900 dark:text-white mb-2">Нет привычек</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-6">Создай свою первую привычку!</p>
|
||||
<button onClick={() => setShowCreateModal(true)} className="btn btn-primary">
|
||||
<Plus size={20} className="mr-2" />
|
||||
Создать привычку
|
||||
</button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<>
|
||||
{/* Активные привычки */}
|
||||
<div className="space-y-3">
|
||||
<AnimatePresence>
|
||||
{activeHabits.map((habit, index) => (
|
||||
@@ -143,32 +126,17 @@ export default function Habits() {
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Архивные привычки */}
|
||||
{archivedList.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<button
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
<button onClick={() => setShowArchived(!showArchived)} className="flex items-center gap-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 mb-4">
|
||||
<Archive size={18} />
|
||||
<span className="font-medium">Архив ({archivedList.length})</span>
|
||||
<ChevronRight
|
||||
size={18}
|
||||
className={clsx(
|
||||
'transition-transform',
|
||||
showArchived && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<ChevronRight size={18} className={clsx('transition-transform', showArchived && 'rotate-90')} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showArchived && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} className="space-y-3">
|
||||
{archivedList.map((habit, index) => (
|
||||
<motion.div
|
||||
key={habit.id}
|
||||
@@ -178,21 +146,14 @@ export default function Habits() {
|
||||
className="card p-4 opacity-60"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center text-xl"
|
||||
style={{ backgroundColor: habit.color + '20' }}
|
||||
>
|
||||
<div className="w-12 h-12 rounded-xl flex items-center justify-center text-xl" style={{ backgroundColor: habit.color + '20' }}>
|
||||
{habit.icon || '✨'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-600 truncate">{habit.name}</h3>
|
||||
<p className="text-sm text-gray-400">{getFrequencyLabel(habit)}</p>
|
||||
<h3 className="font-semibold text-gray-600 dark:text-gray-400 truncate">{habit.name}</h3>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">{getFrequencyLabel(habit)}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => archiveMutation.mutate({ id: habit.id, archived: false })}
|
||||
className="p-2 text-gray-400 hover:text-green-500 hover:bg-green-50 rounded-xl transition-all"
|
||||
title="Восстановить"
|
||||
>
|
||||
<button onClick={() => archiveMutation.mutate({ id: habit.id, archived: false })} className="p-2 text-gray-400 hover:text-green-500 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-xl transition-all" title="Восстановить">
|
||||
<ArchiveRestore size={20} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -208,17 +169,8 @@ export default function Habits() {
|
||||
</main>
|
||||
|
||||
<Navigation />
|
||||
|
||||
<CreateHabitModal
|
||||
open={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
/>
|
||||
|
||||
<EditHabitModal
|
||||
open={!!editingHabit}
|
||||
onClose={() => setEditingHabit(null)}
|
||||
habit={editingHabit}
|
||||
/>
|
||||
<CreateHabitModal open={showCreateModal} onClose={() => setShowCreateModal(false)} />
|
||||
<EditHabitModal open={!!editingHabit} onClose={() => setEditingHabit(null)} habit={editingHabit} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -234,20 +186,14 @@ function HabitListItem({ habit, index, stats, frequencyLabel, onEdit, onArchive
|
||||
className="card p-4 cursor-pointer hover:shadow-lg transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-14 h-14 rounded-2xl flex items-center justify-center text-2xl flex-shrink-0"
|
||||
style={{ backgroundColor: habit.color + '15' }}
|
||||
>
|
||||
<div className="w-14 h-14 rounded-2xl flex items-center justify-center text-2xl flex-shrink-0" style={{ backgroundColor: habit.color + '15' }}>
|
||||
{habit.icon || '✨'}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{habit.name}</h3>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate">{habit.name}</h3>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span
|
||||
className="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: habit.color + '15', color: habit.color }}
|
||||
>
|
||||
<span className="text-xs font-medium px-2 py-0.5 rounded-full" style={{ backgroundColor: habit.color + '15', color: habit.color }}>
|
||||
{frequencyLabel}
|
||||
</span>
|
||||
{stats && stats.current_streak > 0 && (
|
||||
@@ -262,11 +208,11 @@ function HabitListItem({ habit, index, stats, frequencyLabel, onEdit, onArchive
|
||||
<div className="flex items-center gap-2">
|
||||
{stats && (
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-gray-900">{stats.this_month}</p>
|
||||
<p className="text-xs text-gray-400">в месяц</p>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">{stats.this_month}</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">в месяц</p>
|
||||
</div>
|
||||
)}
|
||||
<ChevronRight size={20} className="text-gray-300" />
|
||||
<ChevronRight size={20} className="text-gray-300 dark:text-gray-600" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,51 +1,62 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Check, Flame, TrendingUp, Zap, Sparkles, Undo2, Plus, Calendar, AlertTriangle, LogOut } from 'lucide-react'
|
||||
import { format, startOfWeek, differenceInDays, parseISO, isToday, isTomorrow, isPast } from 'date-fns'
|
||||
import { Check, Flame, TrendingUp, Zap, Sparkles, Undo2, Plus, Calendar, AlertTriangle, LogOut, Snowflake } from 'lucide-react'
|
||||
import { format, startOfWeek, differenceInDays, parseISO, isToday, isTomorrow, isPast, startOfDay, isBefore, isAfter } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
import { habitsApi } from '../api/habits'
|
||||
import { tasksApi } from '../api/tasks'
|
||||
import { useAuthStore } from '../store/auth'
|
||||
import Navigation from '../components/Navigation'
|
||||
import CreateTaskModal from '../components/CreateTaskModal'
|
||||
import LogHabitModal from '../components/LogHabitModal'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// Check if habit is frozen on a specific date
|
||||
function isHabitFrozenOnDate(habit, freezes, date) {
|
||||
if (!freezes || freezes.length === 0) return false
|
||||
const checkDate = startOfDay(date)
|
||||
return freezes.some(freeze => {
|
||||
const start = startOfDay(parseISO(freeze.start_date))
|
||||
const end = startOfDay(parseISO(freeze.end_date))
|
||||
return !isBefore(checkDate, start) && !isAfter(checkDate, end)
|
||||
})
|
||||
}
|
||||
|
||||
// Определение "сегодняшних" привычек
|
||||
function shouldShowToday(habit, lastLogDate) {
|
||||
const today = new Date()
|
||||
const dayOfWeek = today.getDay() || 7 // 1=Пн, 7=Вс (JS: 0=Вс -> 7)
|
||||
function shouldShowToday(habit, lastLogDate, freezes) {
|
||||
const today = startOfDay(new Date())
|
||||
const dayOfWeek = today.getDay() || 7
|
||||
|
||||
if (habit.frequency === 'daily') {
|
||||
return true
|
||||
}
|
||||
if (isHabitFrozenOnDate(habit, freezes, today)) return false
|
||||
|
||||
if (habit.frequency === 'weekly') {
|
||||
// Проверяем, выбран ли сегодняшний день
|
||||
if (habit.target_days && habit.target_days.includes(dayOfWeek)) {
|
||||
return true
|
||||
const startDate = habit.start_date
|
||||
? startOfDay(parseISO(habit.start_date))
|
||||
: startOfDay(parseISO(habit.created_at))
|
||||
|
||||
if (today < startDate) return false
|
||||
|
||||
if (habit.frequency === "daily") return true
|
||||
|
||||
if (habit.frequency === "weekly") {
|
||||
if (habit.target_days && habit.target_days.length > 0) {
|
||||
return habit.target_days.includes(dayOfWeek)
|
||||
}
|
||||
|
||||
// Если не выполнялась на этой неделе
|
||||
if (!lastLogDate) return true
|
||||
|
||||
const weekStart = startOfWeek(today, { weekStartsOn: 1 })
|
||||
const lastLog = typeof lastLogDate === 'string' ? parseISO(lastLogDate) : lastLogDate
|
||||
|
||||
if (lastLog < weekStart) {
|
||||
return true // Не выполнялась на этой неделе
|
||||
}
|
||||
|
||||
return false
|
||||
const lastLog = typeof lastLogDate === "string" ? parseISO(lastLogDate) : lastLogDate
|
||||
return lastLog < weekStart
|
||||
}
|
||||
|
||||
// custom (every_n_days) - показывать если прошло N+ дней
|
||||
if (habit.frequency === 'custom' && habit.target_count > 0) {
|
||||
if (!lastLogDate) return true
|
||||
|
||||
const lastLog = typeof lastLogDate === 'string' ? parseISO(lastLogDate) : lastLogDate
|
||||
const daysSinceLastLog = differenceInDays(today, lastLog)
|
||||
|
||||
if (habit.frequency === "interval" && habit.target_count > 0) {
|
||||
const daysSinceStart = differenceInDays(today, startDate)
|
||||
return daysSinceStart % habit.target_count === 0
|
||||
}
|
||||
|
||||
if (habit.frequency === "custom" && habit.target_count > 0) {
|
||||
if (!lastLogDate) return today >= startDate
|
||||
const lastLog = typeof lastLogDate === "string" ? parseISO(lastLogDate) : lastLogDate
|
||||
const daysSinceLastLog = differenceInDays(today, startOfDay(lastLog))
|
||||
return daysSinceLastLog >= habit.target_count
|
||||
}
|
||||
|
||||
@@ -63,7 +74,10 @@ function formatDueDate(dateStr) {
|
||||
export default function Home() {
|
||||
const [todayLogs, setTodayLogs] = useState({})
|
||||
const [lastLogDates, setLastLogDates] = useState({})
|
||||
const [habitFreezes, setHabitFreezes] = useState({})
|
||||
const [habitLogs, setHabitLogs] = useState({})
|
||||
const [showCreateTask, setShowCreateTask] = useState(false)
|
||||
const [logHabitModal, setLogHabitModal] = useState({ open: false, habit: null })
|
||||
const queryClient = useQueryClient()
|
||||
const { user, logout } = useAuthStore()
|
||||
|
||||
@@ -85,6 +99,7 @@ export default function Home() {
|
||||
useEffect(() => {
|
||||
if (habits.length > 0) {
|
||||
loadTodayLogs()
|
||||
loadHabitFreezes()
|
||||
}
|
||||
}, [habits])
|
||||
|
||||
@@ -92,20 +107,18 @@ export default function Home() {
|
||||
const today = format(new Date(), 'yyyy-MM-dd')
|
||||
const logsMap = {}
|
||||
const lastDates = {}
|
||||
const allLogs = {}
|
||||
|
||||
await Promise.all(habits.map(async (habit) => {
|
||||
try {
|
||||
const logs = await habitsApi.getLogs(habit.id, 30)
|
||||
const logs = await habitsApi.getLogs(habit.id, 90)
|
||||
allLogs[habit.id] = logs.map(l => l.date)
|
||||
|
||||
// Находим последний лог
|
||||
if (logs.length > 0) {
|
||||
const lastLog = logs[0]
|
||||
const logDate = lastLog.date.split('T')[0]
|
||||
lastDates[habit.id] = logDate
|
||||
|
||||
if (logDate === today) {
|
||||
logsMap[habit.id] = lastLog.id
|
||||
}
|
||||
if (logDate === today) logsMap[habit.id] = lastLog.id
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading logs for habit', habit.id, e)
|
||||
@@ -114,14 +127,31 @@ export default function Home() {
|
||||
|
||||
setTodayLogs(logsMap)
|
||||
setLastLogDates(lastDates)
|
||||
setHabitLogs(allLogs)
|
||||
}
|
||||
|
||||
const loadHabitFreezes = async () => {
|
||||
const freezesMap = {}
|
||||
await Promise.all(habits.map(async (habit) => {
|
||||
try {
|
||||
const freezes = await habitsApi.getFreezes(habit.id)
|
||||
freezesMap[habit.id] = freezes
|
||||
} catch (e) {
|
||||
freezesMap[habit.id] = []
|
||||
}
|
||||
}))
|
||||
setHabitFreezes(freezesMap)
|
||||
}
|
||||
|
||||
const logMutation = useMutation({
|
||||
mutationFn: (habitId) => habitsApi.log(habitId),
|
||||
onSuccess: (data, habitId) => {
|
||||
mutationFn: ({ habitId, date }) => habitsApi.log(habitId, date ? { date } : {}),
|
||||
onSuccess: (data, { habitId, date }) => {
|
||||
const logDate = date || format(new Date(), 'yyyy-MM-dd')
|
||||
const today = format(new Date(), 'yyyy-MM-dd')
|
||||
setTodayLogs(prev => ({ ...prev, [habitId]: data.id }))
|
||||
setLastLogDates(prev => ({ ...prev, [habitId]: today }))
|
||||
|
||||
if (logDate === today) setTodayLogs(prev => ({ ...prev, [habitId]: data.id }))
|
||||
setLastLogDates(prev => ({ ...prev, [habitId]: logDate }))
|
||||
setHabitLogs(prev => ({ ...prev, [habitId]: [...(prev[habitId] || []), logDate] }))
|
||||
queryClient.invalidateQueries({ queryKey: ['habits'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['stats'] })
|
||||
},
|
||||
@@ -135,7 +165,6 @@ export default function Home() {
|
||||
delete newLogs[habitId]
|
||||
return newLogs
|
||||
})
|
||||
// Перезагружаем логи для обновления lastLogDate
|
||||
loadTodayLogs()
|
||||
queryClient.invalidateQueries({ queryKey: ['habits'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['stats'] })
|
||||
@@ -162,50 +191,51 @@ export default function Home() {
|
||||
if (todayLogs[habitId]) {
|
||||
deleteLogMutation.mutate({ habitId, logId: todayLogs[habitId] })
|
||||
} else {
|
||||
logMutation.mutate(habitId)
|
||||
logMutation.mutate({ habitId })
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogHabitDate = async (habitId, date) => {
|
||||
await logMutation.mutateAsync({ habitId, date })
|
||||
}
|
||||
|
||||
const handleToggleTask = (task) => {
|
||||
if (task.completed) {
|
||||
uncompleteTaskMutation.mutate(task.id)
|
||||
} else {
|
||||
completeTaskMutation.mutate(task.id)
|
||||
}
|
||||
if (task.completed) uncompleteTaskMutation.mutate(task.id)
|
||||
else completeTaskMutation.mutate(task.id)
|
||||
}
|
||||
|
||||
// Фильтруем привычки для сегодня
|
||||
const todayHabits = useMemo(() => {
|
||||
return habits.filter(habit =>
|
||||
shouldShowToday(habit, lastLogDates[habit.id])
|
||||
)
|
||||
}, [habits, lastLogDates])
|
||||
return habits.filter(habit => shouldShowToday(habit, lastLogDates[habit.id], habitFreezes[habit.id]))
|
||||
}, [habits, lastLogDates, habitFreezes])
|
||||
|
||||
const frozenHabits = useMemo(() => {
|
||||
const today = startOfDay(new Date())
|
||||
return habits.filter(habit => isHabitFrozenOnDate(habit, habitFreezes[habit.id], today))
|
||||
}, [habits, habitFreezes])
|
||||
|
||||
const completedCount = Object.keys(todayLogs).length
|
||||
const totalToday = todayHabits.length
|
||||
const today = format(new Date(), 'EEEE, d MMMM', { locale: ru })
|
||||
|
||||
// Активные задачи (не выполненные)
|
||||
const activeTasks = todayTasks.filter(t => !t.completed)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-50 gradient-mesh pb-24">
|
||||
<header className="bg-white/70 backdrop-blur-xl border-b border-gray-100/50 sticky top-0 z-10">
|
||||
<div className="min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300">
|
||||
<header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 sticky top-0 z-10">
|
||||
<div className="max-w-lg mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center shadow-lg shadow-primary-500/20">
|
||||
<Zap className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-display font-bold text-gray-900">
|
||||
<h1 className="text-lg font-display font-bold text-gray-900 dark:text-white">
|
||||
Привет, {user?.username}!
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 capitalize">{today}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 capitalize">{today}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-xl transition-colors"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors"
|
||||
title="Выйти"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
@@ -214,19 +244,13 @@ export default function Home() {
|
||||
</header>
|
||||
|
||||
<main className="max-w-lg mx-auto px-4 py-6 space-y-6">
|
||||
{/* Прогресс на сегодня */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="card p-5"
|
||||
>
|
||||
{/* Progress */}
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="card p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-semibold text-gray-900">Прогресс на сегодня</h2>
|
||||
<span className="text-sm font-medium text-primary-600">
|
||||
{completedCount} / {totalToday}
|
||||
</span>
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white">Прогресс на сегодня</h2>
|
||||
<span className="text-sm font-medium text-primary-600 dark:text-primary-400">{completedCount} / {totalToday}</span>
|
||||
</div>
|
||||
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-3 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: totalToday > 0 ? `${(completedCount / totalToday) * 100}%` : '0%' }}
|
||||
@@ -235,62 +259,55 @@ export default function Home() {
|
||||
/>
|
||||
</div>
|
||||
{completedCount === totalToday && totalToday > 0 && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-sm text-green-600 mt-2 font-medium"
|
||||
>
|
||||
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-sm text-green-600 dark:text-green-400 mt-2 font-medium">
|
||||
🎉 Все привычки выполнены!
|
||||
</motion.p>
|
||||
)}
|
||||
{frozenHabits.length > 0 && (
|
||||
<div className="flex items-center gap-2 mt-2 text-sm text-cyan-600 dark:text-cyan-400">
|
||||
<Snowflake size={14} />
|
||||
<span>{frozenHabits.length} привычек на паузе</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Статистика */}
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="card p-5"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="card p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-accent-400 to-accent-500 flex items-center justify-center shadow-lg shadow-accent-400/20">
|
||||
<Flame className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-display font-bold text-gray-900">{stats.today_completed}</p>
|
||||
<p className="text-sm text-gray-500">Выполнено</p>
|
||||
<p className="text-3xl font-display font-bold text-gray-900 dark:text-white">{stats.today_completed}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Выполнено</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="card p-5"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }} className="card p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center shadow-lg shadow-primary-500/20">
|
||||
<TrendingUp className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-display font-bold text-gray-900">{stats.active_habits}</p>
|
||||
<p className="text-sm text-gray-500">Активных</p>
|
||||
<p className="text-3xl font-display font-bold text-gray-900 dark:text-white">{stats.active_habits}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Активных</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Задачи на сегодня */}
|
||||
{/* Tasks */}
|
||||
{(activeTasks.length > 0 || !tasksLoading) && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-display font-bold text-gray-900">Задачи на сегодня</h2>
|
||||
<h2 className="text-xl font-display font-bold text-gray-900 dark:text-white">Задачи на сегодня</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateTask(true)}
|
||||
className="p-2 bg-primary-100 text-primary-600 rounded-xl hover:bg-primary-200 transition-colors"
|
||||
className="p-2 bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded-xl hover:bg-primary-200 dark:hover:bg-primary-800/40 transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
@@ -299,24 +316,17 @@ export default function Home() {
|
||||
{tasksLoading ? (
|
||||
<div className="card p-5 animate-pulse">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-gray-200" />
|
||||
<div className="w-10 h-10 rounded-xl bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="flex-1">
|
||||
<div className="h-5 bg-gray-200 rounded-lg w-3/4 mb-2" />
|
||||
<div className="h-4 bg-gray-200 rounded-lg w-1/4" />
|
||||
<div className="h-5 bg-gray-200 dark:bg-gray-700 rounded-lg w-3/4 mb-2" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : activeTasks.length === 0 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="card p-6 text-center"
|
||||
>
|
||||
<p className="text-gray-500">Нет задач на сегодня</p>
|
||||
<button
|
||||
onClick={() => setShowCreateTask(true)}
|
||||
className="mt-3 text-sm text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="card p-6 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">Нет задач на сегодня</p>
|
||||
<button onClick={() => setShowCreateTask(true)} className="mt-3 text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 font-medium">
|
||||
+ Добавить задачу
|
||||
</button>
|
||||
</motion.div>
|
||||
@@ -324,13 +334,7 @@ export default function Home() {
|
||||
<div className="space-y-3">
|
||||
<AnimatePresence>
|
||||
{activeTasks.map((task, index) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
index={index}
|
||||
onToggle={() => handleToggleTask(task)}
|
||||
isLoading={completeTaskMutation.isPending || uncompleteTaskMutation.isPending}
|
||||
/>
|
||||
<TaskCard key={task.id} task={task} index={index} onToggle={() => handleToggleTask(task)} isLoading={completeTaskMutation.isPending || uncompleteTaskMutation.isPending} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
@@ -338,35 +342,31 @@ export default function Home() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Привычки на сегодня */}
|
||||
{/* Habits */}
|
||||
<div>
|
||||
<h2 className="text-xl font-display font-bold text-gray-900 mb-5">Привычки</h2>
|
||||
<h2 className="text-xl font-display font-bold text-gray-900 dark:text-white mb-5">Привычки</h2>
|
||||
|
||||
{habitsLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="card p-5 animate-pulse">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gray-200" />
|
||||
<div className="w-14 h-14 rounded-2xl bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="flex-1">
|
||||
<div className="h-5 bg-gray-200 rounded-lg w-1/2 mb-2" />
|
||||
<div className="h-4 bg-gray-200 rounded-lg w-1/3" />
|
||||
<div className="h-5 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/2 mb-2" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : todayHabits.length === 0 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="card p-10 text-center"
|
||||
>
|
||||
<div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-green-100 to-green-200 flex items-center justify-center mx-auto mb-5">
|
||||
<Sparkles className="w-10 h-10 text-green-600" />
|
||||
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="card p-10 text-center">
|
||||
<div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-green-100 to-green-200 dark:from-green-900/30 dark:to-green-800/30 flex items-center justify-center mx-auto mb-5">
|
||||
<Sparkles className="w-10 h-10 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h3 className="text-xl font-display font-bold text-gray-900 mb-2">Свободный день!</h3>
|
||||
<p className="text-gray-500">На сегодня нет запланированных привычек. Отдохни или добавь новую во вкладке "Привычки".</p>
|
||||
<h3 className="text-xl font-display font-bold text-gray-900 dark:text-white mb-2">Свободный день!</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">На сегодня нет запланированных привычек.</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
@@ -378,6 +378,7 @@ export default function Home() {
|
||||
index={index}
|
||||
isCompleted={!!todayLogs[habit.id]}
|
||||
onToggle={() => handleToggleComplete(habit.id)}
|
||||
onLongPress={() => setLogHabitModal({ open: true, habit })}
|
||||
isLoading={logMutation.isPending || deleteLogMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
@@ -388,10 +389,13 @@ export default function Home() {
|
||||
</main>
|
||||
|
||||
<Navigation />
|
||||
|
||||
<CreateTaskModal
|
||||
open={showCreateTask}
|
||||
onClose={() => setShowCreateTask(false)}
|
||||
<CreateTaskModal open={showCreateTask} onClose={() => setShowCreateTask(false)} />
|
||||
<LogHabitModal
|
||||
open={logHabitModal.open}
|
||||
onClose={() => setLogHabitModal({ open: false, habit: null })}
|
||||
habit={logHabitModal.habit}
|
||||
completedDates={habitLogs[logHabitModal.habit?.id] || []}
|
||||
onLogDate={handleLogHabitDate}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -421,21 +425,12 @@ function TaskCard({ task, index, onToggle, isLoading }) {
|
||||
className="card p-4 relative overflow-hidden"
|
||||
>
|
||||
{showConfetti && (
|
||||
<motion.div
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: 0 }}
|
||||
transition={{ duration: 1 }}
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
>
|
||||
<motion.div initial={{ opacity: 1 }} animate={{ opacity: 0 }} transition={{ duration: 1 }} className="absolute inset-0 pointer-events-none">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ x: '50%', y: '50%', scale: 0 }}
|
||||
animate={{
|
||||
x: `${Math.random() * 100}%`,
|
||||
y: `${Math.random() * 100}%`,
|
||||
scale: [0, 1, 0]
|
||||
}}
|
||||
animate={{ x: `${Math.random() * 100}%`, y: `${Math.random() * 100}%`, scale: [0, 1, 0] }}
|
||||
transition={{ duration: 0.6, delay: i * 0.05 }}
|
||||
className="absolute w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: task.color }}
|
||||
@@ -455,17 +450,10 @@ function TaskCard({ task, index, onToggle, isLoading }) {
|
||||
? 'bg-gradient-to-br from-green-400 to-green-500 shadow-lg shadow-green-400/30'
|
||||
: 'border-2 hover:shadow-md'
|
||||
)}
|
||||
style={{
|
||||
borderColor: task.completed ? undefined : task.color + '40',
|
||||
backgroundColor: task.completed ? undefined : task.color + '10'
|
||||
}}
|
||||
style={{ borderColor: task.completed ? undefined : task.color + '40', backgroundColor: task.completed ? undefined : task.color + '10' }}
|
||||
>
|
||||
{task.completed ? (
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 500 }}
|
||||
>
|
||||
<motion.div initial={{ scale: 0, rotate: -180 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', stiffness: 500 }}>
|
||||
<Check className="w-5 h-5 text-white" strokeWidth={3} />
|
||||
</motion.div>
|
||||
) : (
|
||||
@@ -474,16 +462,9 @@ function TaskCard({ task, index, onToggle, isLoading }) {
|
||||
</motion.button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className={clsx(
|
||||
"font-semibold truncate",
|
||||
task.completed ? "text-gray-400 line-through" : "text-gray-900"
|
||||
)}>{task.title}</h3>
|
||||
|
||||
<h3 className={clsx("font-semibold truncate", task.completed ? "text-gray-400 line-through" : "text-gray-900 dark:text-white")}>{task.title}</h3>
|
||||
{(dueDateLabel || isOverdue) && (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-1 text-xs font-medium mt-1',
|
||||
isOverdue ? 'text-red-600' : 'text-gray-500'
|
||||
)}>
|
||||
<span className={clsx('inline-flex items-center gap-1 text-xs font-medium mt-1', isOverdue ? 'text-red-600' : 'text-gray-500 dark:text-gray-400')}>
|
||||
{isOverdue && <AlertTriangle size={12} />}
|
||||
<Calendar size={12} />
|
||||
{dueDateLabel}
|
||||
@@ -492,14 +473,7 @@ function TaskCard({ task, index, onToggle, isLoading }) {
|
||||
</div>
|
||||
|
||||
{task.completed && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
onClick={handleCheck}
|
||||
disabled={isLoading}
|
||||
className="p-2 text-gray-400 hover:text-orange-500 hover:bg-orange-50 rounded-xl transition-all"
|
||||
title="Отменить"
|
||||
>
|
||||
<motion.button initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} onClick={handleCheck} disabled={isLoading} className="p-2 text-gray-400 hover:text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-900/20 rounded-xl transition-all" title="Отменить">
|
||||
<Undo2 size={18} />
|
||||
</motion.button>
|
||||
)}
|
||||
@@ -508,19 +482,27 @@ function TaskCard({ task, index, onToggle, isLoading }) {
|
||||
)
|
||||
}
|
||||
|
||||
function HabitCard({ habit, index, isCompleted, onToggle, isLoading }) {
|
||||
function HabitCard({ habit, index, isCompleted, onToggle, onLongPress, isLoading }) {
|
||||
const [showConfetti, setShowConfetti] = useState(false)
|
||||
const longPressTimer = useRef(null)
|
||||
const isLongPress = useRef(false)
|
||||
|
||||
const handleTouchStart = () => {
|
||||
isLongPress.current = false
|
||||
longPressTimer.current = setTimeout(() => { isLongPress.current = true; onLongPress() }, 500)
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => { if (longPressTimer.current) clearTimeout(longPressTimer.current) }
|
||||
|
||||
const handleCheck = (e) => {
|
||||
e.stopPropagation()
|
||||
if (isLoading) return
|
||||
if (!isCompleted) {
|
||||
setShowConfetti(true)
|
||||
setTimeout(() => setShowConfetti(false), 1000)
|
||||
}
|
||||
if (isLoading || isLongPress.current) return
|
||||
if (!isCompleted) { setShowConfetti(true); setTimeout(() => setShowConfetti(false), 1000) }
|
||||
onToggle()
|
||||
}
|
||||
|
||||
const handleContextMenu = (e) => { e.preventDefault(); onLongPress() }
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@@ -528,23 +510,15 @@ function HabitCard({ habit, index, isCompleted, onToggle, isLoading }) {
|
||||
exit={{ opacity: 0, x: -100 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="card p-5 relative overflow-hidden"
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{showConfetti && (
|
||||
<motion.div
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: 0 }}
|
||||
transition={{ duration: 1 }}
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
>
|
||||
<motion.div initial={{ opacity: 1 }} animate={{ opacity: 0 }} transition={{ duration: 1 }} className="absolute inset-0 pointer-events-none">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ x: '50%', y: '50%', scale: 0 }}
|
||||
animate={{
|
||||
x: `${Math.random() * 100}%`,
|
||||
y: `${Math.random() * 100}%`,
|
||||
scale: [0, 1, 0]
|
||||
}}
|
||||
animate={{ x: `${Math.random() * 100}%`, y: `${Math.random() * 100}%`, scale: [0, 1, 0] }}
|
||||
transition={{ duration: 0.6, delay: i * 0.05 }}
|
||||
className="absolute w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: habit.color }}
|
||||
@@ -556,6 +530,11 @@ function HabitCard({ habit, index, isCompleted, onToggle, isLoading }) {
|
||||
<div className="flex items-center gap-4">
|
||||
<motion.button
|
||||
onClick={handleCheck}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onMouseDown={handleTouchStart}
|
||||
onMouseUp={handleTouchEnd}
|
||||
onMouseLeave={handleTouchEnd}
|
||||
disabled={isLoading}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={clsx(
|
||||
@@ -564,17 +543,10 @@ function HabitCard({ habit, index, isCompleted, onToggle, isLoading }) {
|
||||
? 'bg-gradient-to-br from-green-400 to-green-500 shadow-lg shadow-green-400/30'
|
||||
: 'border-2 hover:shadow-md'
|
||||
)}
|
||||
style={{
|
||||
borderColor: isCompleted ? undefined : habit.color + '40',
|
||||
backgroundColor: isCompleted ? undefined : habit.color + '10'
|
||||
}}
|
||||
style={{ borderColor: isCompleted ? undefined : habit.color + '40', backgroundColor: isCompleted ? undefined : habit.color + '10' }}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 500 }}
|
||||
>
|
||||
<motion.div initial={{ scale: 0, rotate: -180 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', stiffness: 500 }}>
|
||||
<Check className="w-7 h-7 text-white" strokeWidth={3} />
|
||||
</motion.div>
|
||||
) : (
|
||||
@@ -583,27 +555,20 @@ function HabitCard({ habit, index, isCompleted, onToggle, isLoading }) {
|
||||
</motion.button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className={clsx(
|
||||
"font-semibold text-lg truncate",
|
||||
isCompleted ? "text-gray-400 line-through" : "text-gray-900"
|
||||
)}>{habit.name}</h3>
|
||||
{habit.description && (
|
||||
<p className="text-sm text-gray-500 truncate">{habit.description}</p>
|
||||
)}
|
||||
<h3 className={clsx("font-semibold text-lg truncate", isCompleted ? "text-gray-400 line-through" : "text-gray-900 dark:text-white")}>{habit.name}</h3>
|
||||
{habit.description && <p className="text-sm text-gray-500 dark:text-gray-400 truncate">{habit.description}</p>}
|
||||
</div>
|
||||
|
||||
{isCompleted && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
onClick={handleCheck}
|
||||
disabled={isLoading}
|
||||
className="p-2 text-gray-400 hover:text-orange-500 hover:bg-orange-50 rounded-xl transition-all"
|
||||
title="Отменить"
|
||||
>
|
||||
<Undo2 size={20} />
|
||||
</motion.button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={(e) => { e.stopPropagation(); onLongPress() }} className="p-2 text-gray-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-xl transition-all" title="Отметить за другой день">
|
||||
<Calendar size={20} />
|
||||
</button>
|
||||
{isCompleted && (
|
||||
<motion.button initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} onClick={handleCheck} disabled={isLoading} className="p-2 text-gray-400 hover:text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-900/20 rounded-xl transition-all" title="Отменить">
|
||||
<Undo2 size={20} />
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
@@ -30,108 +30,48 @@ export default function Login() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 gradient-mesh bg-surface-50">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
<div className="min-h-screen flex items-center justify-center p-4 gradient-mesh bg-surface-50 dark:bg-gray-950 transition-colors duration-300">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: 'spring', delay: 0.1, stiffness: 200 }}
|
||||
className="inline-flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-primary-500 to-primary-700 mb-6 shadow-xl shadow-primary-500/30"
|
||||
>
|
||||
<motion.div initial={{ scale: 0, rotate: -180 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', delay: 0.1, stiffness: 200 }} className="inline-flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-primary-500 to-primary-700 mb-6 shadow-xl shadow-primary-500/30">
|
||||
<Zap className="w-10 h-10 text-white" />
|
||||
</motion.div>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-3xl font-display font-bold text-gray-900"
|
||||
>
|
||||
<motion.h1 initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.2 }} className="text-3xl font-display font-bold text-gray-900 dark:text-white">
|
||||
С возвращением!
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="text-gray-500 mt-2"
|
||||
>
|
||||
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.3 }} className="text-gray-500 dark:text-gray-400 mt-2">
|
||||
Войди, чтобы продолжить
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="card p-8"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} className="card p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
className="p-4 rounded-2xl bg-red-50 text-red-600 text-sm font-medium"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} className="p-4 rounded-2xl bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm font-medium">
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
/>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Email</label>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input" placeholder="your@email.com" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Пароль
|
||||
</label>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Пароль</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input pr-12"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<input type={showPassword ? 'text' : 'password'} value={password} onChange={(e) => setPassword(e.target.value)} className="input pr-12" placeholder="••••••••" required />
|
||||
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-sm text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
Забыли пароль?
|
||||
</Link>
|
||||
<Link to="/forgot-password" className="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 font-medium">Забыли пароль?</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary w-full text-lg"
|
||||
>
|
||||
<button type="submit" disabled={loading} className="btn btn-primary w-full text-lg">
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||
@@ -144,12 +84,9 @@ export default function Login() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-gray-100 text-center">
|
||||
<p className="text-gray-500">
|
||||
Нет аккаунта?{' '}
|
||||
<Link to="/register" className="text-primary-600 hover:text-primary-700 font-semibold">
|
||||
Зарегистрируйся
|
||||
</Link>
|
||||
<div className="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Нет аккаунта?{' '}<Link to="/register" className="text-primary-600 dark:text-primary-400 hover:text-primary-700 font-semibold">Зарегистрируйся</Link>
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -31,103 +31,51 @@ export default function Register() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-primary-50 via-white to-accent-50">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-primary-50 via-white to-accent-50 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 transition-colors duration-300">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', delay: 0.1 }}
|
||||
className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-primary-500 to-accent-500 mb-4"
|
||||
>
|
||||
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', delay: 0.1 }} className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-primary-500 to-accent-500 mb-4">
|
||||
<Sparkles className="w-8 h-8 text-white" />
|
||||
</motion.div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Создай аккаунт</h1>
|
||||
<p className="text-gray-500 mt-1">Начни отслеживать свои привычки</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Создай аккаунт</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">Начни отслеживать свои привычки</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
className="p-3 rounded-xl bg-red-50 text-red-600 text-sm"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} className="p-3 rounded-xl bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm">
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Как тебя зовут?
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="input"
|
||||
placeholder="Имя"
|
||||
required
|
||||
/>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Как тебя зовут?</label>
|
||||
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} className="input" placeholder="Имя" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
/>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Email</label>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input" placeholder="your@email.com" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Пароль
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Пароль</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input pr-12"
|
||||
placeholder="Минимум 8 символов"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<input type={showPassword ? 'text' : 'password'} value={password} onChange={(e) => setPassword(e.target.value)} className="input pr-12" placeholder="Минимум 8 символов" minLength={8} required />
|
||||
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary w-full"
|
||||
>
|
||||
<button type="submit" disabled={loading} className="btn btn-primary w-full">
|
||||
{loading ? 'Создаём...' : 'Создать аккаунт'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-6">
|
||||
Уже есть аккаунт?{' '}
|
||||
<Link to="/login" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||
Войти
|
||||
</Link>
|
||||
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-6">
|
||||
Уже есть аккаунт?{' '}<Link to="/login" className="text-primary-600 dark:text-primary-400 hover:text-primary-700 font-medium">Войти</Link>
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
1396
src/pages/Savings.jsx
Normal file
1396
src/pages/Savings.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { ArrowLeft, Bell, MessageCircle, Globe, Save, Copy, Check, User, Sun, Moon } from "lucide-react"
|
||||
import { ArrowLeft, Bell, MessageCircle, Globe, Save, Copy, Check, User, Sun, Moon, Palette } from "lucide-react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { profileApi } from "../api/profile"
|
||||
import { useTheme } from "../contexts/ThemeContext"
|
||||
import Navigation from "../components/Navigation"
|
||||
|
||||
const TIMEZONES = [
|
||||
@@ -26,6 +27,7 @@ const TIMEZONES = [
|
||||
|
||||
export default function Settings() {
|
||||
const queryClient = useQueryClient()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [username, setUsername] = useState("")
|
||||
const [chatId, setChatId] = useState("")
|
||||
@@ -99,39 +101,80 @@ export default function Settings() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-50 flex items-center justify-center">
|
||||
<div className="min-h-screen bg-surface-50 dark:bg-gray-950 flex items-center justify-center">
|
||||
<div className="w-10 h-10 border-4 border-primary-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-50 pb-24">
|
||||
<div className="min-h-screen bg-surface-50 dark:bg-gray-950 pb-24 transition-colors duration-300">
|
||||
{/* Header */}
|
||||
<header className="bg-white/80 backdrop-blur-xl sticky top-0 z-40 border-b border-gray-100">
|
||||
<header className="bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl sticky top-0 z-40 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="max-w-lg mx-auto px-4 py-4 flex items-center gap-3">
|
||||
<Link to="/" className="p-2 -ml-2 text-gray-600 hover:text-gray-900 rounded-xl hover:bg-gray-100">
|
||||
<Link to="/" className="p-2 -ml-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
<ArrowLeft size={20} />
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">Настройки</h1>
|
||||
<h1 className="text-xl font-bold dark:text-white">Настройки</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-lg mx-auto px-4 py-6 space-y-6">
|
||||
{/* Profile Section */}
|
||||
<section className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
|
||||
{/* Theme Section */}
|
||||
<section className="bg-white dark:bg-gray-900 rounded-2xl p-4 shadow-sm dark:shadow-none border border-transparent dark:border-gray-800">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center">
|
||||
<User className="text-green-600" size={20} />
|
||||
<div className="w-10 h-10 rounded-xl bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center">
|
||||
<Palette className="text-violet-600 dark:text-violet-400" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold">Профиль</h2>
|
||||
<p className="text-sm text-gray-500">Основная информация</p>
|
||||
<h2 className="font-semibold dark:text-white">Оформление</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Выбери тему приложения</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="w-full flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-xl transition-all hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{theme === "dark" ? (
|
||||
<Moon className="text-primary-500" size={22} />
|
||||
) : (
|
||||
<Sun className="text-amber-500" size={22} />
|
||||
)}
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{theme === "dark" ? "Тёмная тема" : "Светлая тема"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className={`w-14 h-8 rounded-full transition-colors duration-300 ${theme === "dark" ? "bg-primary-500" : "bg-gray-300"}`}>
|
||||
<div className={`absolute top-1 w-6 h-6 bg-white rounded-full shadow-md transition-transform duration-300 flex items-center justify-center ${theme === "dark" ? "translate-x-7" : "translate-x-1"}`}>
|
||||
{theme === "dark" ? (
|
||||
<Moon className="text-primary-600" size={14} />
|
||||
) : (
|
||||
<Sun className="text-amber-500" size={14} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* Profile Section */}
|
||||
<section className="bg-white dark:bg-gray-900 rounded-2xl p-4 shadow-sm dark:shadow-none border border-transparent dark:border-gray-800">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<User className="text-green-600 dark:text-green-400" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold dark:text-white">Профиль</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Основная информация</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Имя пользователя
|
||||
</label>
|
||||
<input
|
||||
@@ -145,36 +188,36 @@ export default function Settings() {
|
||||
</section>
|
||||
|
||||
{/* Telegram Section */}
|
||||
<section className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<section className="bg-white dark:bg-gray-900 rounded-2xl p-4 shadow-sm dark:shadow-none border border-transparent dark:border-gray-800">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center">
|
||||
<MessageCircle className="text-blue-600" size={20} />
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<MessageCircle className="text-blue-600 dark:text-blue-400" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold">Telegram</h2>
|
||||
<p className="text-sm text-gray-500">Получай уведомления в Telegram</p>
|
||||
<h2 className="font-semibold dark:text-white">Telegram</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Получай уведомления в Telegram</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 bg-blue-50 rounded-xl">
|
||||
<p className="text-sm text-blue-800 mb-2">
|
||||
1. Напиши <code className="bg-blue-100 px-1 rounded">/start</code> боту в Telegram
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-xl">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300 mb-2">
|
||||
1. Напиши <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">/start</code> боту в Telegram
|
||||
</p>
|
||||
<button
|
||||
onClick={copyInstruction}
|
||||
className="flex items-center gap-2 text-sm font-medium text-blue-600 hover:text-blue-700"
|
||||
className="flex items-center gap-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
|
||||
>
|
||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||
{copied ? "Скопировано!" : "@pulse_tracking_bot"}
|
||||
</button>
|
||||
<p className="text-sm text-blue-800 mt-2">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300 mt-2">
|
||||
2. Скопируй Chat ID из ответа бота и вставь ниже
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Chat ID
|
||||
</label>
|
||||
<input
|
||||
@@ -189,20 +232,20 @@ export default function Settings() {
|
||||
</section>
|
||||
|
||||
{/* Notifications Section */}
|
||||
<section className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<section className="bg-white dark:bg-gray-900 rounded-2xl p-4 shadow-sm dark:shadow-none border border-transparent dark:border-gray-800">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-orange-100 flex items-center justify-center">
|
||||
<Bell className="text-orange-600" size={20} />
|
||||
<div className="w-10 h-10 rounded-xl bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center">
|
||||
<Bell className="text-orange-600 dark:text-orange-400" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold">Уведомления</h2>
|
||||
<p className="text-sm text-gray-500">Настрой ежедневные уведомления</p>
|
||||
<h2 className="font-semibold dark:text-white">Уведомления</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Настрой ежедневные уведомления</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center justify-between p-3 bg-gray-50 rounded-xl cursor-pointer">
|
||||
<span className="text-sm font-medium">Включить уведомления</span>
|
||||
<label className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-xl cursor-pointer">
|
||||
<span className="text-sm font-medium dark:text-white">Включить уведомления</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -210,42 +253,42 @@ export default function Settings() {
|
||||
onChange={(e) => setNotificationsEnabled(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-300 rounded-full peer peer-checked:bg-primary-500 transition-colors"></div>
|
||||
<div className="w-11 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-primary-500 transition-colors"></div>
|
||||
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full peer-checked:translate-x-5 transition-transform"></div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{notificationsEnabled && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 p-3 bg-yellow-50 rounded-xl">
|
||||
<Sun className="text-yellow-600" size={20} />
|
||||
<div className="flex items-center gap-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-xl">
|
||||
<Sun className="text-yellow-600 dark:text-yellow-400" size={20} />
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Утреннее уведомление
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">Задачи и привычки на сегодня</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Задачи и привычки на сегодня</p>
|
||||
</div>
|
||||
<input
|
||||
type="time"
|
||||
value={morningTime}
|
||||
onChange={(e) => setMorningTime(e.target.value)}
|
||||
className="px-3 py-1.5 border border-gray-200 rounded-lg text-sm"
|
||||
className="px-3 py-1.5 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-white rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-indigo-50 rounded-xl">
|
||||
<Moon className="text-indigo-600" size={20} />
|
||||
<div className="flex items-center gap-3 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-xl">
|
||||
<Moon className="text-indigo-600 dark:text-indigo-400" size={20} />
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Вечернее уведомление
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">Итоги дня: выполнено / осталось</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Итоги дня: выполнено / осталось</p>
|
||||
</div>
|
||||
<input
|
||||
type="time"
|
||||
value={eveningTime}
|
||||
onChange={(e) => setEveningTime(e.target.value)}
|
||||
className="px-3 py-1.5 border border-gray-200 rounded-lg text-sm"
|
||||
className="px-3 py-1.5 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-white rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -254,14 +297,14 @@ export default function Settings() {
|
||||
</section>
|
||||
|
||||
{/* Timezone Section */}
|
||||
<section className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<section className="bg-white dark:bg-gray-900 rounded-2xl p-4 shadow-sm dark:shadow-none border border-transparent dark:border-gray-800">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
|
||||
<Globe className="text-purple-600" size={20} />
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||
<Globe className="text-purple-600 dark:text-purple-400" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold">Часовой пояс</h2>
|
||||
<p className="text-sm text-gray-500">Для корректных напоминаний</p>
|
||||
<h2 className="font-semibold dark:text-white">Часовой пояс</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Для корректных напоминаний</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -291,7 +334,7 @@ export default function Settings() {
|
||||
)}
|
||||
|
||||
{mutation.isSuccess && !hasChanges && (
|
||||
<div className="p-3 rounded-xl bg-green-50 text-green-700 text-sm text-center">
|
||||
<div className="p-3 rounded-xl bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 text-sm text-center">
|
||||
✅ Настройки сохранены
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,374 +1,691 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ChevronLeft, ChevronRight, Flame, Target, TrendingUp, BarChart3 } from 'lucide-react'
|
||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isToday, subMonths, addMonths, parseISO, isSameMonth } from 'date-fns'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { ChevronDown, Flame, Trophy, CheckCircle2, TrendingUp, BarChart3, Calendar, Sparkles, Target, Zap } from 'lucide-react'
|
||||
import { format, subDays, parseISO, startOfDay, differenceInDays, isBefore, isAfter, eachDayOfInterval, startOfMonth, getDay, addDays } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, Area, AreaChart, CartesianGrid } from 'recharts'
|
||||
import { habitsApi } from '../api/habits'
|
||||
import Navigation from '../components/Navigation'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// Получить дату начала привычки
|
||||
function getHabitStartDate(habit) {
|
||||
if (habit.start_date) return startOfDay(parseISO(habit.start_date))
|
||||
if (habit.created_at) return startOfDay(parseISO(habit.created_at))
|
||||
return startOfDay(new Date())
|
||||
}
|
||||
|
||||
// Check if habit is frozen on date
|
||||
function isHabitFrozenOnDate(freezes, date) {
|
||||
if (!freezes || freezes.length === 0) return false
|
||||
const checkDate = startOfDay(date)
|
||||
return freezes.some(freeze => {
|
||||
const start = startOfDay(parseISO(freeze.start_date))
|
||||
const end = startOfDay(parseISO(freeze.end_date))
|
||||
return !isBefore(checkDate, start) && !isAfter(checkDate, end)
|
||||
})
|
||||
}
|
||||
|
||||
// Проверить ожидается ли привычка в дату
|
||||
function isHabitExpectedOnDate(habit, date, freezes) {
|
||||
const checkDate = startOfDay(date)
|
||||
const startDate = getHabitStartDate(habit)
|
||||
if (checkDate < startDate || checkDate > startOfDay(new Date())) return false
|
||||
if (isHabitFrozenOnDate(freezes, date)) return false
|
||||
|
||||
const dayOfWeek = checkDate.getDay() || 7
|
||||
if (habit.frequency === "daily") return true
|
||||
if (habit.frequency === "weekly") {
|
||||
if (habit.target_days?.length > 0) return habit.target_days.includes(dayOfWeek)
|
||||
return true
|
||||
}
|
||||
if (habit.frequency === "interval" && habit.target_count > 0) {
|
||||
const daysSinceStart = differenceInDays(checkDate, startDate)
|
||||
return daysSinceStart % habit.target_count === 0
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Custom Tooltip Component
|
||||
const CustomTooltip = ({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-gray-900/95 backdrop-blur-sm px-4 py-3 rounded-xl shadow-2xl border border-gray-700/50">
|
||||
<p className="text-gray-400 text-xs mb-1">{label}</p>
|
||||
<p className="text-white font-bold text-lg">{payload[0].value}%</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Stat Card Component
|
||||
const StatCard = ({ icon, value, label, emoji, color, delay = 0 }) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ delay, duration: 0.4, ease: "easeOut" }}
|
||||
className="relative overflow-hidden group"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/80 to-white/40 dark:from-gray-800/80 dark:to-gray-900/40 backdrop-blur-xl rounded-2xl" />
|
||||
<div className={clsx(
|
||||
"absolute inset-0 opacity-[0.03] group-hover:opacity-[0.06] transition-opacity duration-500",
|
||||
`bg-gradient-to-br ${color}`
|
||||
)} />
|
||||
<div className="relative p-5 flex items-center gap-4">
|
||||
<div className={clsx(
|
||||
"w-14 h-14 rounded-2xl flex items-center justify-center text-2xl",
|
||||
"bg-gradient-to-br shadow-lg",
|
||||
color
|
||||
)}>
|
||||
{emoji}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">
|
||||
{value}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r opacity-50 rounded-b-2xl"
|
||||
style={{ background: `linear-gradient(to right, var(--tw-gradient-stops))` }} />
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
// Heatmap Cell Component
|
||||
const HeatmapCell = ({ day, getColor, index }) => {
|
||||
const [showTooltip, setShowTooltip] = useState(false)
|
||||
const cellRef = useRef(null)
|
||||
|
||||
return (
|
||||
<div className="relative" ref={cellRef}>
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: index * 0.003, duration: 0.2 }}
|
||||
className={clsx(
|
||||
"w-full aspect-square rounded-[4px] cursor-pointer transition-all duration-200",
|
||||
"hover:ring-2 hover:ring-primary-400 hover:ring-offset-2 hover:ring-offset-gray-900",
|
||||
"hover:scale-110 hover:z-10",
|
||||
getColor(day.count, day.expected)
|
||||
)}
|
||||
onMouseEnter={() => setShowTooltip(true)}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{showTooltip && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 5, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 5, scale: 0.9 }}
|
||||
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50 pointer-events-none"
|
||||
>
|
||||
<div className="bg-gray-900 text-white px-3 py-2 rounded-lg shadow-xl text-xs whitespace-nowrap">
|
||||
<p className="font-medium">{format(day.date, 'd MMMM', { locale: ru })}</p>
|
||||
<p className="text-primary-400 mt-0.5">
|
||||
{day.count}/{day.expected} выполнено
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Section Header Component
|
||||
const SectionHeader = ({ icon: Icon, title, subtitle }) => (
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500/20 to-primary-600/10 flex items-center justify-center">
|
||||
<Icon className="text-primary-500" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white">{title}</h3>
|
||||
{subtitle && <p className="text-xs text-gray-500 dark:text-gray-400">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default function Stats() {
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date())
|
||||
const [selectedHabitId, setSelectedHabitId] = useState(null)
|
||||
const [allHabitLogs, setAllHabitLogs] = useState({})
|
||||
const [allHabitStats, setAllHabitStats] = useState({})
|
||||
const [selectedHabitId, setSelectedHabitId] = useState(null)
|
||||
const [allHabitFreezes, setAllHabitFreezes] = useState({})
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
|
||||
const { data: habits = [] } = useQuery({
|
||||
queryKey: ['habits'],
|
||||
queryFn: habitsApi.list,
|
||||
})
|
||||
|
||||
// Загрузка логов и статистики для всех привычек
|
||||
useEffect(() => {
|
||||
if (habits.length > 0) {
|
||||
loadAllHabitsData()
|
||||
}
|
||||
if (habits.length > 0) loadAllHabitsData()
|
||||
}, [habits])
|
||||
|
||||
const loadAllHabitsData = async () => {
|
||||
const logsMap = {}
|
||||
const statsMap = {}
|
||||
const freezesMap = {}
|
||||
|
||||
await Promise.all(habits.map(async (habit) => {
|
||||
try {
|
||||
const [logs, stats] = await Promise.all([
|
||||
const [logs, stats, freezes] = await Promise.all([
|
||||
habitsApi.getLogs(habit.id, 90),
|
||||
habitsApi.getHabitStats(habit.id),
|
||||
habitsApi.getFreezes(habit.id),
|
||||
])
|
||||
logsMap[habit.id] = logs
|
||||
statsMap[habit.id] = stats
|
||||
freezesMap[habit.id] = freezes
|
||||
} catch (e) {
|
||||
console.error(`Error loading data for habit ${habit.id}:`, e)
|
||||
logsMap[habit.id] = []
|
||||
statsMap[habit.id] = null
|
||||
freezesMap[habit.id] = []
|
||||
}
|
||||
}))
|
||||
|
||||
setAllHabitLogs(logsMap)
|
||||
setAllHabitStats(statsMap)
|
||||
setAllHabitFreezes(freezesMap)
|
||||
}
|
||||
|
||||
const monthStart = startOfMonth(currentMonth)
|
||||
const monthEnd = endOfMonth(currentMonth)
|
||||
const daysInMonth = eachDayOfInterval({ start: monthStart, end: monthEnd })
|
||||
|
||||
const startDayOfWeek = monthStart.getDay()
|
||||
const paddingDays = startDayOfWeek === 0 ? 6 : startDayOfWeek - 1
|
||||
|
||||
// Получить привычки, выполненные в конкретный день
|
||||
const getCompletedHabitsForDate = (date) => {
|
||||
const dateStr = format(date, 'yyyy-MM-dd')
|
||||
return habits.filter(habit => {
|
||||
// Combined stats for all or selected habit
|
||||
const computedStats = useMemo(() => {
|
||||
const targetHabits = selectedHabitId
|
||||
? habits.filter(h => h.id === selectedHabitId)
|
||||
: habits
|
||||
|
||||
let totalLogs = 0
|
||||
let totalExpected = 0
|
||||
let currentStreak = 0
|
||||
let bestStreak = 0
|
||||
|
||||
targetHabits.forEach(habit => {
|
||||
const logs = allHabitLogs[habit.id] || []
|
||||
return logs.some(log => log.date.split('T')[0] === dateStr)
|
||||
const stats = allHabitStats[habit.id]
|
||||
const freezes = allHabitFreezes[habit.id] || []
|
||||
|
||||
totalLogs += logs.length
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const date = subDays(new Date(), i)
|
||||
if (isHabitExpectedOnDate(habit, date, freezes)) totalExpected++
|
||||
}
|
||||
|
||||
if (stats) {
|
||||
currentStreak = Math.max(currentStreak, stats.current_streak || 0)
|
||||
bestStreak = Math.max(bestStreak, stats.longest_streak || 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const rate = totalExpected > 0 ? Math.round((totalLogs / totalExpected) * 100) : 0
|
||||
|
||||
return { totalLogs, currentStreak, bestStreak, rate }
|
||||
}, [selectedHabitId, habits, allHabitLogs, allHabitStats, allHabitFreezes])
|
||||
|
||||
// Подсчёт выполнений за текущий месяц для каждой привычки
|
||||
const monthlyStats = useMemo(() => {
|
||||
const stats = {}
|
||||
habits.forEach(habit => {
|
||||
const logs = allHabitLogs[habit.id] || []
|
||||
const monthLogs = logs.filter(log => {
|
||||
const logDate = parseISO(log.date.split('T')[0])
|
||||
return isSameMonth(logDate, currentMonth)
|
||||
// Heatmap data (12 weeks = 84 days)
|
||||
const heatmapData = useMemo(() => {
|
||||
const today = startOfDay(new Date())
|
||||
const startDate = subDays(today, 83)
|
||||
const days = eachDayOfInterval({ start: startDate, end: today })
|
||||
|
||||
// Align to week start (Monday)
|
||||
const firstDayOfWeek = getDay(startDate) || 7
|
||||
const paddingDays = firstDayOfWeek - 1
|
||||
|
||||
return days.map(day => {
|
||||
const dateStr = format(day, 'yyyy-MM-dd')
|
||||
let count = 0
|
||||
let expected = 0
|
||||
|
||||
const targetHabits = selectedHabitId
|
||||
? habits.filter(h => h.id === selectedHabitId)
|
||||
: habits
|
||||
|
||||
targetHabits.forEach(habit => {
|
||||
const logs = allHabitLogs[habit.id] || []
|
||||
const freezes = allHabitFreezes[habit.id] || []
|
||||
|
||||
if (logs.some(l => l.date.split('T')[0] === dateStr)) count++
|
||||
if (isHabitExpectedOnDate(habit, day, freezes)) expected++
|
||||
})
|
||||
stats[habit.id] = monthLogs.length
|
||||
|
||||
return { date: day, dateStr, count, expected }
|
||||
})
|
||||
return stats
|
||||
}, [habits, allHabitLogs, currentMonth])
|
||||
}, [selectedHabitId, habits, allHabitLogs, allHabitFreezes])
|
||||
|
||||
// Общий % выполнения за месяц
|
||||
const overallMonthlyPercent = useMemo(() => {
|
||||
if (habits.length === 0) return 0
|
||||
const totalPossible = habits.length * daysInMonth.length
|
||||
const totalCompleted = Object.values(monthlyStats).reduce((sum, val) => sum + val, 0)
|
||||
return Math.round((totalCompleted / totalPossible) * 100)
|
||||
}, [habits, monthlyStats, daysInMonth])
|
||||
// Get unique months for heatmap labels
|
||||
const heatmapMonths = useMemo(() => {
|
||||
const months = []
|
||||
let currentMonth = null
|
||||
heatmapData.forEach((day, index) => {
|
||||
const month = format(day.date, 'MMM', { locale: ru })
|
||||
if (month !== currentMonth) {
|
||||
months.push({ month, index: Math.floor(index / 7) })
|
||||
currentMonth = month
|
||||
}
|
||||
})
|
||||
return months
|
||||
}, [heatmapData])
|
||||
|
||||
const prevMonth = () => setCurrentMonth(subMonths(currentMonth, 1))
|
||||
const nextMonth = () => setCurrentMonth(addMonths(currentMonth, 1))
|
||||
// Line chart data (30 days completion rate)
|
||||
const lineChartData = useMemo(() => {
|
||||
const data = []
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const date = subDays(new Date(), i)
|
||||
const dateStr = format(date, 'yyyy-MM-dd')
|
||||
let completed = 0
|
||||
let expected = 0
|
||||
|
||||
const targetHabits = selectedHabitId
|
||||
? habits.filter(h => h.id === selectedHabitId)
|
||||
: habits
|
||||
|
||||
targetHabits.forEach(habit => {
|
||||
const logs = allHabitLogs[habit.id] || []
|
||||
const freezes = allHabitFreezes[habit.id] || []
|
||||
|
||||
if (logs.some(l => l.date.split('T')[0] === dateStr)) completed++
|
||||
if (isHabitExpectedOnDate(habit, date, freezes)) expected++
|
||||
})
|
||||
|
||||
const rate = expected > 0 ? Math.round((completed / expected) * 100) : 0
|
||||
data.push({ date: format(date, 'dd.MM'), fullDate: format(date, 'd MMM', { locale: ru }), rate, completed, expected })
|
||||
}
|
||||
return data
|
||||
}, [selectedHabitId, habits, allHabitLogs, allHabitFreezes])
|
||||
|
||||
// Bar chart data (habits comparison)
|
||||
const barChartData = useMemo(() => {
|
||||
return habits.map(habit => {
|
||||
const logs = allHabitLogs[habit.id] || []
|
||||
const freezes = allHabitFreezes[habit.id] || []
|
||||
|
||||
let expected = 0
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const date = subDays(new Date(), i)
|
||||
if (isHabitExpectedOnDate(habit, date, freezes)) expected++
|
||||
}
|
||||
|
||||
const completed = logs.filter(l => {
|
||||
const logDate = parseISO(l.date.split('T')[0])
|
||||
return differenceInDays(new Date(), logDate) < 30
|
||||
}).length
|
||||
|
||||
const rate = expected > 0 ? Math.round((completed / expected) * 100) : 0
|
||||
|
||||
return {
|
||||
name: habit.name,
|
||||
icon: habit.icon,
|
||||
rate,
|
||||
color: habit.color || '#0d9488',
|
||||
completed,
|
||||
expected
|
||||
}
|
||||
}).sort((a, b) => b.rate - a.rate)
|
||||
}, [habits, allHabitLogs, allHabitFreezes])
|
||||
|
||||
// Heatmap intensity color - 5 levels
|
||||
const getHeatmapColor = (count, expected) => {
|
||||
if (expected === 0) return 'bg-[#1a1a1a]'
|
||||
const ratio = count / expected
|
||||
if (ratio === 0) return 'bg-[#1f1f1f] dark:bg-[#1a1a1a]'
|
||||
if (ratio < 0.4) return 'bg-teal-900/80'
|
||||
if (ratio < 0.7) return 'bg-teal-700'
|
||||
if (ratio < 1) return 'bg-teal-600'
|
||||
return 'bg-teal-400 shadow-sm shadow-teal-400/30'
|
||||
}
|
||||
|
||||
const selectedHabit = habits.find(h => h.id === selectedHabitId)
|
||||
const selectedStats = selectedHabitId ? allHabitStats[selectedHabitId] : null
|
||||
|
||||
// Group heatmap by weeks (columns)
|
||||
const heatmapWeeks = useMemo(() => {
|
||||
const weeks = []
|
||||
for (let i = 0; i < heatmapData.length; i += 7) {
|
||||
weeks.push(heatmapData.slice(i, i + 7))
|
||||
}
|
||||
return weeks
|
||||
}, [heatmapData])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-50 gradient-mesh pb-24">
|
||||
<header className="bg-white/70 backdrop-blur-xl border-b border-gray-100/50 sticky top-0 z-10">
|
||||
<div className="max-w-lg mx-auto px-4 py-4">
|
||||
<h1 className="text-xl font-display font-bold text-gray-900">Статистика</h1>
|
||||
<p className="text-sm text-gray-500">Общий прогресс по привычкам</p>
|
||||
<div className="min-h-screen bg-gray-950 pb-24 transition-colors duration-300">
|
||||
{/* Gradient Background */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-96 h-96 bg-primary-500/10 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-0 w-80 h-80 bg-teal-500/5 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<header className="relative bg-gray-900/50 backdrop-blur-xl border-b border-gray-800/50 sticky top-0 z-20">
|
||||
<div className="max-w-lg mx-auto px-5 py-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-11 h-11 rounded-2xl bg-gradient-to-br from-primary-500 to-teal-600 flex items-center justify-center shadow-lg shadow-primary-500/25">
|
||||
<BarChart3 className="text-white" size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
Статистика
|
||||
<Sparkles className="text-primary-400" size={16} />
|
||||
</h1>
|
||||
<p className="text-sm text-gray-400">Отслеживай свой прогресс</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-lg mx-auto px-4 py-6 space-y-6">
|
||||
<main className="relative max-w-lg mx-auto px-5 py-6 space-y-8">
|
||||
|
||||
{/* Легенда с привычками */}
|
||||
{habits.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="card p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Привычки</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">Общий прогресс:</span>
|
||||
<span className="text-sm font-bold text-primary-600">{overallMonthlyPercent}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{habits.map((habit) => (
|
||||
<button
|
||||
key={habit.id}
|
||||
onClick={() => setSelectedHabitId(selectedHabitId === habit.id ? null : habit.id)}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm transition-all',
|
||||
selectedHabitId === habit.id
|
||||
? 'ring-2 ring-offset-1'
|
||||
: 'hover:bg-gray-100'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: selectedHabitId === habit.id ? habit.color + '20' : undefined,
|
||||
ringColor: habit.color,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: habit.color }}
|
||||
/>
|
||||
<span className="text-gray-700">{habit.icon}</span>
|
||||
<span className="text-gray-600 font-medium">{habit.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Календарь */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
{/* Habit Selector Dropdown */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="card p-5"
|
||||
className="relative"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-all"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<h3 className="text-lg font-semibold text-gray-900 capitalize">
|
||||
{format(currentMonth, 'LLLL yyyy', { locale: ru })}
|
||||
</h3>
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-all"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Дни недели */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map((day) => (
|
||||
<div key={day} className="text-center text-xs font-medium text-gray-400 py-2">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Дни месяца */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{[...Array(paddingDays)].map((_, i) => (
|
||||
<div key={'pad' + i} className="aspect-square" />
|
||||
))}
|
||||
|
||||
{daysInMonth.map((day) => {
|
||||
const completedHabits = getCompletedHabitsForDate(day)
|
||||
const today = isToday(day)
|
||||
const future = day > new Date()
|
||||
const completedCount = completedHabits.length
|
||||
const totalCount = habits.length
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
<button
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
className="w-full bg-gray-900/80 backdrop-blur-xl border border-gray-800 rounded-2xl p-4 flex items-center justify-between hover:border-gray-700 transition-all duration-300"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedHabit ? (
|
||||
<>
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center text-xl"
|
||||
style={{ backgroundColor: selectedHabit.color + '20' }}
|
||||
>
|
||||
{selectedHabit.icon}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className="font-semibold text-white block">{selectedHabit.name}</span>
|
||||
<span className="text-xs text-gray-500">Выбранная привычка</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500/20 to-teal-500/10 flex items-center justify-center">
|
||||
<Target className="text-primary-400" size={20} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className="font-semibold text-white block">Все привычки</span>
|
||||
<span className="text-xs text-gray-500">{habits.length} привычек</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className={clsx(
|
||||
"text-gray-500 transition-transform duration-300",
|
||||
dropdownOpen && "rotate-180"
|
||||
)} size={20} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{dropdownOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute top-full left-0 right-0 mt-2 bg-gray-900 backdrop-blur-xl rounded-2xl shadow-2xl border border-gray-800 z-30 overflow-hidden max-h-80 overflow-y-auto"
|
||||
>
|
||||
<button
|
||||
onClick={() => { setSelectedHabitId(null); setDropdownOpen(false) }}
|
||||
className={clsx(
|
||||
'aspect-square flex flex-col items-center justify-center rounded-lg relative p-1',
|
||||
today && 'ring-2 ring-primary-500 bg-primary-50',
|
||||
future && 'opacity-40',
|
||||
!today && completedCount > 0 && 'bg-gray-50'
|
||||
"w-full p-4 flex items-center gap-3 hover:bg-gray-800 transition-colors border-b border-gray-800/50",
|
||||
!selectedHabitId && "bg-primary-500/10"
|
||||
)}
|
||||
>
|
||||
{/* Число дня */}
|
||||
<span className={clsx(
|
||||
'text-sm font-medium',
|
||||
today ? 'text-primary-600' : completedCount > 0 ? 'text-gray-900' : 'text-gray-400'
|
||||
)}>
|
||||
{format(day, 'd')}
|
||||
</span>
|
||||
|
||||
{/* Цветные точки выполненных привычек */}
|
||||
{completedCount > 0 && (
|
||||
<div className="flex flex-wrap gap-0.5 justify-center mt-0.5">
|
||||
{completedHabits.slice(0, 4).map((habit) => (
|
||||
<motion.div
|
||||
key={habit.id}
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: habit.color }}
|
||||
/>
|
||||
))}
|
||||
{completedHabits.length > 4 && (
|
||||
<span className="text-[8px] text-gray-400">+{completedHabits.length - 4}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* X/Y в углу */}
|
||||
{totalCount > 0 && !future && (
|
||||
<span className={clsx(
|
||||
'absolute bottom-0.5 right-0.5 text-[8px] font-medium',
|
||||
completedCount === totalCount ? 'text-green-500' :
|
||||
completedCount > 0 ? 'text-gray-400' : 'text-gray-300'
|
||||
)}>
|
||||
{completedCount}/{totalCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Target className="text-primary-400" size={20} />
|
||||
<span className="font-medium text-white">Все привычки</span>
|
||||
{!selectedHabitId && <div className="ml-auto w-2 h-2 rounded-full bg-primary-400" />}
|
||||
</button>
|
||||
{habits.map(habit => (
|
||||
<button
|
||||
key={habit.id}
|
||||
onClick={() => { setSelectedHabitId(habit.id); setDropdownOpen(false) }}
|
||||
className={clsx(
|
||||
"w-full p-4 flex items-center gap-3 hover:bg-gray-800 transition-colors",
|
||||
selectedHabitId === habit.id && "bg-primary-500/10"
|
||||
)}
|
||||
>
|
||||
<span className="text-xl">{habit.icon}</span>
|
||||
<span className="font-medium text-white">{habit.name}</span>
|
||||
{selectedHabitId === habit.id && <div className="ml-auto w-2 h-2 rounded-full bg-primary-400" />}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Monthly Summary - карточки для каждой привычки */}
|
||||
{habits.length > 0 && (
|
||||
<motion.div
|
||||
{/* Stats Cards */}
|
||||
<section>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<StatCard
|
||||
emoji="🔥"
|
||||
value={computedStats.currentStreak}
|
||||
label="Текущий streak"
|
||||
color="from-orange-500/20 to-red-500/10"
|
||||
delay={0}
|
||||
/>
|
||||
<StatCard
|
||||
emoji="🏆"
|
||||
value={computedStats.bestStreak}
|
||||
label="Лучший streak"
|
||||
color="from-yellow-500/20 to-amber-500/10"
|
||||
delay={0.1}
|
||||
/>
|
||||
<StatCard
|
||||
emoji="✅"
|
||||
value={computedStats.totalLogs}
|
||||
label="Всего выполнено"
|
||||
color="from-green-500/20 to-emerald-500/10"
|
||||
delay={0.2}
|
||||
/>
|
||||
<StatCard
|
||||
emoji="📈"
|
||||
value={`${computedStats.rate}%`}
|
||||
label="Completion rate"
|
||||
color="from-primary-500/20 to-teal-500/10"
|
||||
delay={0.3}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Heatmap Calendar */}
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-gray-900/60 backdrop-blur-xl border border-gray-800/50 rounded-3xl p-6"
|
||||
>
|
||||
<SectionHeader
|
||||
icon={Calendar}
|
||||
title="Активность"
|
||||
subtitle="Последние 12 недель"
|
||||
/>
|
||||
|
||||
{/* Month labels */}
|
||||
<div className="flex mb-2 ml-8">
|
||||
{heatmapMonths.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-[10px] text-gray-500 capitalize"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${32 + m.index * 18}px`
|
||||
}}
|
||||
>
|
||||
{m.month}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 mt-6">
|
||||
{/* Day labels */}
|
||||
<div className="flex flex-col gap-[3px] pr-2">
|
||||
{['Пн', '', 'Ср', '', 'Пт', '', 'Вс'].map((d, i) => (
|
||||
<div key={i} className="h-[14px] text-[10px] text-gray-500 flex items-center">
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Heatmap grid */}
|
||||
<div className="flex gap-[3px] flex-1">
|
||||
{heatmapWeeks.map((week, weekIndex) => (
|
||||
<div key={weekIndex} className="flex flex-col gap-[3px] flex-1">
|
||||
{week.map((day, dayIndex) => (
|
||||
<HeatmapCell
|
||||
key={day.dateStr}
|
||||
day={day}
|
||||
getColor={getHeatmapColor}
|
||||
index={weekIndex * 7 + dayIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center justify-end gap-2 mt-5 pt-4 border-t border-gray-800/50">
|
||||
<span className="text-xs text-gray-500">Меньше</span>
|
||||
<div className="flex gap-1">
|
||||
<div className="w-3.5 h-3.5 rounded-[3px] bg-[#1f1f1f]" />
|
||||
<div className="w-3.5 h-3.5 rounded-[3px] bg-teal-900/80" />
|
||||
<div className="w-3.5 h-3.5 rounded-[3px] bg-teal-700" />
|
||||
<div className="w-3.5 h-3.5 rounded-[3px] bg-teal-600" />
|
||||
<div className="w-3.5 h-3.5 rounded-[3px] bg-teal-400" />
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">Больше</span>
|
||||
</div>
|
||||
</motion.section>
|
||||
|
||||
{/* Line Chart - Completion Rate */}
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="bg-gray-900/60 backdrop-blur-xl border border-gray-800/50 rounded-3xl p-6"
|
||||
>
|
||||
<SectionHeader
|
||||
icon={TrendingUp}
|
||||
title="Completion Rate"
|
||||
subtitle="Динамика за 30 дней"
|
||||
/>
|
||||
|
||||
<div className="h-56">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={lineChartData}>
|
||||
<defs>
|
||||
<linearGradient id="colorRate" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#0d9488" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="#0d9488" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="#374151"
|
||||
strokeOpacity={0.3}
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 10, fill: '#6b7280' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: '#6b7280' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
domain={[0, 100]}
|
||||
width={30}
|
||||
tickFormatter={(v) => `${v}%`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="rate"
|
||||
stroke="#0d9488"
|
||||
strokeWidth={2.5}
|
||||
fill="url(#colorRate)"
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
fill: '#0d9488',
|
||||
stroke: '#fff',
|
||||
strokeWidth: 2,
|
||||
className: 'drop-shadow-lg'
|
||||
}}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</motion.section>
|
||||
|
||||
{/* Bar Chart - Habits Comparison */}
|
||||
{!selectedHabitId && habits.length > 1 && (
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
className="card p-5"
|
||||
transition={{ delay: 0.4 }}
|
||||
className="bg-gray-900/60 backdrop-blur-xl border border-gray-800/50 rounded-3xl p-6"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-primary-500" />
|
||||
Итоги месяца
|
||||
</h3>
|
||||
<SectionHeader
|
||||
icon={BarChart3}
|
||||
title="По привычкам"
|
||||
subtitle="Рейтинг за 30 дней"
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
{habits.map((habit) => {
|
||||
const completed = monthlyStats[habit.id] || 0
|
||||
const total = daysInMonth.length
|
||||
const percent = Math.round((completed / total) * 100)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={habit.id}
|
||||
className="p-3 rounded-xl bg-gray-50 hover:bg-gray-100 transition-all cursor-pointer"
|
||||
onClick={() => setSelectedHabitId(selectedHabitId === habit.id ? null : habit.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: habit.color }}
|
||||
/>
|
||||
<span className="text-lg">{habit.icon}</span>
|
||||
<span className="font-medium text-gray-900">{habit.name}</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-gray-700">{completed}/{total}</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${percent}%` }}
|
||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||
className="h-full rounded-full"
|
||||
style={{ backgroundColor: habit.color }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right mt-1">
|
||||
<span className="text-xs text-gray-500">{percent}%</span>
|
||||
</div>
|
||||
{barChartData.map((habit, index) => (
|
||||
<motion.div
|
||||
key={habit.name}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 * index }}
|
||||
className="relative"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-lg">{habit.icon}</span>
|
||||
<span className="text-sm font-medium text-white flex-1 truncate">{habit.name}</span>
|
||||
<span className="text-sm font-bold text-primary-400">{habit.rate}%</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${habit.rate}%` }}
|
||||
transition={{ delay: 0.2 + 0.1 * index, duration: 0.8, ease: "easeOut" }}
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
backgroundColor: habit.color,
|
||||
boxShadow: `0 0 10px ${habit.color}50`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className="text-[10px] text-gray-500">{habit.completed} выполнено</span>
|
||||
<span className="text-[10px] text-gray-500">{habit.expected} ожидалось</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Детальная статистика для выбранной привычки */}
|
||||
{selectedHabit && selectedStats && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="card p-5"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center text-xl"
|
||||
style={{ backgroundColor: selectedHabit.color + '20' }}
|
||||
>
|
||||
{selectedHabit.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{selectedHabit.name}</h3>
|
||||
<p className="text-sm text-gray-500">Детальная статистика</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Streak карточки */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
<div className="text-center p-3 bg-gradient-to-br from-accent-50 to-accent-100 rounded-xl">
|
||||
<Flame className="w-5 h-5 text-accent-500 mx-auto mb-1" />
|
||||
<p className="text-xl font-bold text-gray-900">{selectedStats.current_streak}</p>
|
||||
<p className="text-xs text-gray-500">Текущий</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-gradient-to-br from-primary-50 to-primary-100 rounded-xl">
|
||||
<TrendingUp className="w-5 h-5 text-primary-500 mx-auto mb-1" />
|
||||
<p className="text-xl font-bold text-gray-900">{selectedStats.longest_streak}</p>
|
||||
<p className="text-xs text-gray-500">Лучший</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-gradient-to-br from-green-50 to-green-100 rounded-xl">
|
||||
<Target className="w-5 h-5 text-green-500 mx-auto mb-1" />
|
||||
<p className="text-xl font-bold text-gray-900">{Math.round(selectedStats.completion_pct || 0)}%</p>
|
||||
<p className="text-xs text-gray-500">Выполнение</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Детальные данные */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center py-2 border-b border-gray-100">
|
||||
<span className="text-gray-600">Всего выполнений</span>
|
||||
<span className="font-semibold text-gray-900">{selectedStats.total_logs || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b border-gray-100">
|
||||
<span className="text-gray-600">За эту неделю</span>
|
||||
<span className="font-semibold text-gray-900">{selectedStats.this_week || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b border-gray-100">
|
||||
<span className="text-gray-600">За этот месяц</span>
|
||||
<span className="font-semibold text-gray-900">{selectedStats.this_month || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-gray-600">Дата создания</span>
|
||||
<span className="font-semibold text-gray-900">
|
||||
{format(parseISO(selectedHabit.created_at), 'd MMM yyyy', { locale: ru })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.section>
|
||||
)}
|
||||
|
||||
{habits.length === 0 && (
|
||||
<div className="card p-10 text-center">
|
||||
<p className="text-gray-500">Создайте привычки, чтобы видеть статистику</p>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="bg-gray-900/60 backdrop-blur-xl border border-gray-800/50 rounded-3xl p-12 text-center"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-2xl bg-gray-800 flex items-center justify-center mx-auto mb-4">
|
||||
<Zap className="text-gray-600" size={32} />
|
||||
</div>
|
||||
<p className="text-gray-400 font-medium">Создайте привычки,</p>
|
||||
<p className="text-gray-500 text-sm">чтобы видеть статистику</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Plus, Check, Circle, Calendar, AlertTriangle, Undo2, Edit2 } from 'lucide-react'
|
||||
import { Plus, Check, Circle, Calendar, AlertTriangle, Undo2, Edit2, Repeat } from 'lucide-react'
|
||||
import { format, parseISO, isToday, isTomorrow, isPast } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
import { tasksApi } from '../api/tasks'
|
||||
@@ -12,9 +12,16 @@ import clsx from 'clsx'
|
||||
|
||||
const PRIORITY_LABELS = {
|
||||
0: null,
|
||||
1: { label: 'Низкий', class: 'bg-blue-100 text-blue-700' },
|
||||
2: { label: 'Средний', class: 'bg-yellow-100 text-yellow-700' },
|
||||
3: { label: 'Высокий', class: 'bg-red-100 text-red-700' },
|
||||
1: { label: 'Низкий', class: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
2: { label: 'Средний', class: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' },
|
||||
3: { label: 'Высокий', class: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' },
|
||||
}
|
||||
|
||||
const RECURRENCE_LABELS = {
|
||||
daily: 'Ежедневно',
|
||||
weekly: 'Еженедельно',
|
||||
monthly: 'Ежемесячно',
|
||||
custom: 'Повтор',
|
||||
}
|
||||
|
||||
function formatDueDate(dateStr) {
|
||||
@@ -28,7 +35,7 @@ function formatDueDate(dateStr) {
|
||||
export default function Tasks() {
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [editingTask, setEditingTask] = useState(null)
|
||||
const [filter, setFilter] = useState('active') // all, active, completed
|
||||
const [filter, setFilter] = useState('active')
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: tasks = [], isLoading } = useQuery({
|
||||
@@ -56,31 +63,21 @@ export default function Tasks() {
|
||||
})
|
||||
|
||||
const handleToggle = (task) => {
|
||||
if (task.completed) {
|
||||
uncompleteMutation.mutate(task.id)
|
||||
} else {
|
||||
completeMutation.mutate(task.id)
|
||||
}
|
||||
if (task.completed) uncompleteMutation.mutate(task.id)
|
||||
else completeMutation.mutate(task.id)
|
||||
}
|
||||
|
||||
const activeTasks = tasks.filter(t => !t.completed)
|
||||
const completedTasks = tasks.filter(t => t.completed)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-50 gradient-mesh pb-24">
|
||||
<header className="bg-white/70 backdrop-blur-xl border-b border-gray-100/50 sticky top-0 z-10">
|
||||
<div className="min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300">
|
||||
<header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 sticky top-0 z-10">
|
||||
<div className="max-w-lg mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-display font-bold text-gray-900">Задачи</h1>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="p-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-colors shadow-lg shadow-primary-500/30"
|
||||
>
|
||||
<h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">Задачи</h1>
|
||||
<button onClick={() => setShowCreate(true)} className="p-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-colors shadow-lg shadow-primary-500/30">
|
||||
<Plus size={22} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Фильтры */}
|
||||
<div className="flex gap-2 mt-4">
|
||||
{[
|
||||
{ key: 'active', label: 'Активные' },
|
||||
@@ -94,7 +91,7 @@ export default function Tasks() {
|
||||
'px-4 py-2 rounded-xl text-sm font-medium transition-all',
|
||||
filter === key
|
||||
? 'bg-primary-500 text-white shadow-lg shadow-primary-500/30'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
@@ -110,35 +107,28 @@ export default function Tasks() {
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="card p-5 animate-pulse">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-gray-200" />
|
||||
<div className="w-10 h-10 rounded-xl bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="flex-1">
|
||||
<div className="h-5 bg-gray-200 rounded-lg w-3/4 mb-2" />
|
||||
<div className="h-4 bg-gray-200 rounded-lg w-1/4" />
|
||||
<div className="h-5 bg-gray-200 dark:bg-gray-700 rounded-lg w-3/4 mb-2" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="card p-10 text-center"
|
||||
>
|
||||
<div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-primary-100 to-primary-200 flex items-center justify-center mx-auto mb-5">
|
||||
<Check className="w-10 h-10 text-primary-600" />
|
||||
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="card p-10 text-center">
|
||||
<div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-primary-100 to-primary-200 dark:from-primary-900/30 dark:to-primary-800/30 flex items-center justify-center mx-auto mb-5">
|
||||
<Check className="w-10 h-10 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<h3 className="text-xl font-display font-bold text-gray-900 mb-2">
|
||||
<h3 className="text-xl font-display font-bold text-gray-900 dark:text-white mb-2">
|
||||
{filter === 'active' ? 'Нет активных задач' : filter === 'completed' ? 'Нет выполненных задач' : 'Нет задач'}
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-6">
|
||||
{filter === 'active' ? 'Добавь новую задачу или выбери другой фильтр' : 'Выполняй задачи и они появятся здесь'}
|
||||
</p>
|
||||
{filter === 'active' && (
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<button onClick={() => setShowCreate(true)} className="btn btn-primary">
|
||||
<Plus size={18} />
|
||||
Добавить задачу
|
||||
</button>
|
||||
@@ -148,14 +138,7 @@ export default function Tasks() {
|
||||
<div className="space-y-4">
|
||||
<AnimatePresence>
|
||||
{tasks.map((task, index) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
index={index}
|
||||
onToggle={() => handleToggle(task)}
|
||||
onEdit={() => setEditingTask(task)}
|
||||
isLoading={completeMutation.isPending || uncompleteMutation.isPending}
|
||||
/>
|
||||
<TaskCard key={task.id} task={task} index={index} onToggle={() => handleToggle(task)} onEdit={() => setEditingTask(task)} isLoading={completeMutation.isPending || uncompleteMutation.isPending} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
@@ -163,17 +146,8 @@ export default function Tasks() {
|
||||
</main>
|
||||
|
||||
<Navigation />
|
||||
|
||||
<CreateTaskModal
|
||||
open={showCreate}
|
||||
onClose={() => setShowCreate(false)}
|
||||
/>
|
||||
|
||||
<EditTaskModal
|
||||
open={!!editingTask}
|
||||
onClose={() => setEditingTask(null)}
|
||||
task={editingTask}
|
||||
/>
|
||||
<CreateTaskModal open={showCreate} onClose={() => setShowCreate(false)} />
|
||||
<EditTaskModal open={!!editingTask} onClose={() => setEditingTask(null)} task={editingTask} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -187,10 +161,7 @@ function TaskCard({ task, index, onToggle, onEdit, isLoading }) {
|
||||
const handleCheck = (e) => {
|
||||
e.stopPropagation()
|
||||
if (isLoading) return
|
||||
if (!task.completed) {
|
||||
setShowConfetti(true)
|
||||
setTimeout(() => setShowConfetti(false), 1000)
|
||||
}
|
||||
if (!task.completed) { setShowConfetti(true); setTimeout(() => setShowConfetti(false), 1000) }
|
||||
onToggle()
|
||||
}
|
||||
|
||||
@@ -203,25 +174,9 @@ function TaskCard({ task, index, onToggle, onEdit, isLoading }) {
|
||||
className="card p-4 relative overflow-hidden"
|
||||
>
|
||||
{showConfetti && (
|
||||
<motion.div
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: 0 }}
|
||||
transition={{ duration: 1 }}
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
>
|
||||
<motion.div initial={{ opacity: 1 }} animate={{ opacity: 0 }} transition={{ duration: 1 }} className="absolute inset-0 pointer-events-none">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ x: '50%', y: '50%', scale: 0 }}
|
||||
animate={{
|
||||
x: `${Math.random() * 100}%`,
|
||||
y: `${Math.random() * 100}%`,
|
||||
scale: [0, 1, 0]
|
||||
}}
|
||||
transition={{ duration: 0.6, delay: i * 0.05 }}
|
||||
className="absolute w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: task.color }}
|
||||
/>
|
||||
<motion.div key={i} initial={{ x: '50%', y: '50%', scale: 0 }} animate={{ x: `${Math.random() * 100}%`, y: `${Math.random() * 100}%`, scale: [0, 1, 0] }} transition={{ duration: 0.6, delay: i * 0.05 }} className="absolute w-2 h-2 rounded-full" style={{ backgroundColor: task.color }} />
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -233,21 +188,12 @@ function TaskCard({ task, index, onToggle, onEdit, isLoading }) {
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={clsx(
|
||||
'w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 flex-shrink-0 mt-0.5',
|
||||
task.completed
|
||||
? 'bg-gradient-to-br from-green-400 to-green-500 shadow-lg shadow-green-400/30'
|
||||
: 'border-2 hover:shadow-md'
|
||||
task.completed ? 'bg-gradient-to-br from-green-400 to-green-500 shadow-lg shadow-green-400/30' : 'border-2 hover:shadow-md'
|
||||
)}
|
||||
style={{
|
||||
borderColor: task.completed ? undefined : task.color + '40',
|
||||
backgroundColor: task.completed ? undefined : task.color + '10'
|
||||
}}
|
||||
style={{ borderColor: task.completed ? undefined : task.color + '40', backgroundColor: task.completed ? undefined : task.color + '10' }}
|
||||
>
|
||||
{task.completed ? (
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 500 }}
|
||||
>
|
||||
<motion.div initial={{ scale: 0, rotate: -180 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', stiffness: 500 }}>
|
||||
<Check className="w-5 h-5 text-white" strokeWidth={3} />
|
||||
</motion.div>
|
||||
) : (
|
||||
@@ -256,57 +202,40 @@ function TaskCard({ task, index, onToggle, onEdit, isLoading }) {
|
||||
</motion.button>
|
||||
|
||||
<div className="flex-1 min-w-0" onClick={onEdit}>
|
||||
<h3 className={clsx(
|
||||
"font-semibold truncate cursor-pointer hover:text-primary-600",
|
||||
task.completed ? "text-gray-400 line-through" : "text-gray-900"
|
||||
)}>{task.title}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className={clsx("font-semibold truncate cursor-pointer hover:text-primary-600 dark:hover:text-primary-400", task.completed ? "text-gray-400 line-through" : "text-gray-900 dark:text-white")}>{task.title}</h3>
|
||||
{task.is_recurring && <span className="text-sm" title={RECURRENCE_LABELS[task.recurrence_type] || 'Повторяется'}>🔄</span>}
|
||||
</div>
|
||||
|
||||
{task.description && (
|
||||
<p className="text-sm text-gray-500 truncate mt-0.5">{task.description}</p>
|
||||
)}
|
||||
{task.description && <p className="text-sm text-gray-500 dark:text-gray-400 truncate mt-0.5">{task.description}</p>}
|
||||
|
||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||
{dueDateLabel && (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium',
|
||||
isOverdue
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
)}>
|
||||
<span className={clsx('inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium', isOverdue ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400')}>
|
||||
{isOverdue && <AlertTriangle size={12} />}
|
||||
<Calendar size={12} />
|
||||
{dueDateLabel}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{priorityInfo && (
|
||||
<span className={clsx(
|
||||
'px-2 py-0.5 rounded-md text-xs font-medium',
|
||||
priorityInfo.class
|
||||
)}>
|
||||
{priorityInfo.label}
|
||||
{priorityInfo && <span className={clsx('px-2 py-0.5 rounded-md text-xs font-medium', priorityInfo.class)}>{priorityInfo.label}</span>}
|
||||
|
||||
{task.is_recurring && task.recurrence_type && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
|
||||
<Repeat size={12} />
|
||||
{RECURRENCE_LABELS[task.recurrence_type]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-2 text-gray-400 hover:text-primary-500 hover:bg-primary-50 rounded-xl transition-all"
|
||||
>
|
||||
<button onClick={onEdit} className="p-2 text-gray-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-xl transition-all">
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
|
||||
{task.completed && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
onClick={handleCheck}
|
||||
disabled={isLoading}
|
||||
className="p-2 text-gray-400 hover:text-orange-500 hover:bg-orange-50 rounded-xl transition-all"
|
||||
title="Отменить"
|
||||
>
|
||||
<motion.button initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} onClick={handleCheck} disabled={isLoading} className="p-2 text-gray-400 hover:text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-900/20 rounded-xl transition-all" title="Отменить">
|
||||
<Undo2 size={16} />
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
1
storybook-static/favicon.svg
Normal file
1
storybook-static/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svgjs="http://svgjs.com/svgjs" xmlns:xlink="http://www.w3.org/1999/xlink" width="164" height="164" version="1.1"><svg xmlns="http://www.w3.org/2000/svg" width="164" height="164" fill="none" viewBox="0 0 164 164"><path fill="#FF4785" d="M22.467 147.762 17.5 15.402a8.062 8.062 0 0 1 7.553-8.35L137.637.016a8.061 8.061 0 0 1 8.565 8.047v144.23a8.063 8.063 0 0 1-8.424 8.054l-107.615-4.833a8.062 8.062 0 0 1-7.695-7.752Z"/><path fill="#fff" fill-rule="evenodd" d="m128.785.57-15.495.968-.755 18.172a1.203 1.203 0 0 0 1.928 1.008l7.06-5.354 5.962 4.697a1.202 1.202 0 0 0 1.946-.987L128.785.569Zm-12.059 60.856c-2.836 2.203-23.965 3.707-23.965.57.447-11.969-4.912-12.494-7.889-12.494-2.828 0-7.59.855-7.59 7.267 0 6.534 6.96 10.223 15.13 14.553 11.607 6.15 25.654 13.594 25.654 32.326 0 17.953-14.588 27.871-33.194 27.871-19.201 0-35.981-7.769-34.086-34.702.744-3.163 25.156-2.411 25.156 0-.298 11.114 2.232 14.383 8.633 14.383 4.912 0 7.144-2.708 7.144-7.267 0-6.9-7.252-10.973-15.595-15.657C64.827 81.933 51.53 74.468 51.53 57.34c0-17.098 11.76-28.497 32.747-28.497 20.988 0 32.449 11.224 32.449 32.584Z" clip-rule="evenodd"/></svg><style>@media (prefers-color-scheme:light){:root{filter:none}}</style></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
173
storybook-static/index.html
Normal file
173
storybook-static/index.html
Normal file
@@ -0,0 +1,173 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
<title>@storybook/core - Storybook</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'Nunito Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./sb-common-assets/nunito-sans-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Nunito Sans';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./sb-common-assets/nunito-sans-italic.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Nunito Sans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./sb-common-assets/nunito-sans-bold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Nunito Sans';
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./sb-common-assets/nunito-sans-bold-italic.woff2') format('woff2');
|
||||
}
|
||||
</style>
|
||||
|
||||
<link href="./sb-manager/runtime.js" rel="modulepreload" />
|
||||
|
||||
|
||||
<link href="./sb-addons/storybook-core-core-server-presets-0/common-manager-bundle.js" rel="modulepreload" />
|
||||
|
||||
<link href="./sb-addons/essentials-controls-1/manager-bundle.js" rel="modulepreload" />
|
||||
|
||||
<link href="./sb-addons/essentials-actions-2/manager-bundle.js" rel="modulepreload" />
|
||||
|
||||
<link href="./sb-addons/essentials-docs-3/manager-bundle.js" rel="modulepreload" />
|
||||
|
||||
<link href="./sb-addons/essentials-backgrounds-4/manager-bundle.js" rel="modulepreload" />
|
||||
|
||||
<link href="./sb-addons/essentials-viewport-5/manager-bundle.js" rel="modulepreload" />
|
||||
|
||||
<link href="./sb-addons/essentials-toolbars-6/manager-bundle.js" rel="modulepreload" />
|
||||
|
||||
<link href="./sb-addons/essentials-measure-7/manager-bundle.js" rel="modulepreload" />
|
||||
|
||||
<link href="./sb-addons/essentials-outline-8/manager-bundle.js" rel="modulepreload" />
|
||||
|
||||
<link href="./sb-addons/themes-9/manager-bundle.js" rel="modulepreload" />
|
||||
|
||||
|
||||
<style>
|
||||
#storybook-root[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
window['FEATURES'] = {
|
||||
"argTypeTargetsV7": true,
|
||||
"legacyDecoratorFileOrder": false,
|
||||
"disallowImplicitActionsInRenderV8": true
|
||||
};
|
||||
|
||||
|
||||
|
||||
window['REFS'] = {};
|
||||
|
||||
|
||||
|
||||
window['LOGLEVEL'] = "info";
|
||||
|
||||
|
||||
|
||||
window['DOCS_OPTIONS'] = {
|
||||
"defaultName": "Docs",
|
||||
"autodocs": "tag"
|
||||
};
|
||||
|
||||
|
||||
|
||||
window['CONFIG_TYPE'] = "PRODUCTION";
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
window['TAGS_OPTIONS'] = {
|
||||
"dev-only": {
|
||||
"excludeFromDocsStories": true
|
||||
},
|
||||
"docs-only": {
|
||||
"excludeFromSidebar": true
|
||||
},
|
||||
"test-only": {
|
||||
"excludeFromSidebar": true,
|
||||
"excludeFromDocsStories": true
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
window['STORYBOOK_RENDERER'] = "react";
|
||||
|
||||
|
||||
|
||||
window['STORYBOOK_BUILDER'] = "@storybook/builder-vite";
|
||||
|
||||
|
||||
|
||||
window['STORYBOOK_FRAMEWORK'] = "@storybook/react-vite";
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<script type="module">
|
||||
import './sb-manager/globals-runtime.js';
|
||||
|
||||
|
||||
import './sb-addons/storybook-core-core-server-presets-0/common-manager-bundle.js';
|
||||
|
||||
import './sb-addons/essentials-controls-1/manager-bundle.js';
|
||||
|
||||
import './sb-addons/essentials-actions-2/manager-bundle.js';
|
||||
|
||||
import './sb-addons/essentials-docs-3/manager-bundle.js';
|
||||
|
||||
import './sb-addons/essentials-backgrounds-4/manager-bundle.js';
|
||||
|
||||
import './sb-addons/essentials-viewport-5/manager-bundle.js';
|
||||
|
||||
import './sb-addons/essentials-toolbars-6/manager-bundle.js';
|
||||
|
||||
import './sb-addons/essentials-measure-7/manager-bundle.js';
|
||||
|
||||
import './sb-addons/essentials-outline-8/manager-bundle.js';
|
||||
|
||||
import './sb-addons/themes-9/manager-bundle.js';
|
||||
|
||||
|
||||
import './sb-manager/runtime.js';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
storybook-static/index.json
Normal file
1
storybook-static/index.json
Normal file
@@ -0,0 +1 @@
|
||||
{"v":5,"entries":{"ui-button--primary":{"type":"story","id":"ui-button--primary","name":"Primary","title":"UI/Button","importPath":"./src/stories/Buttons.stories.jsx","tags":["dev","test"]},"ui-button--secondary":{"type":"story","id":"ui-button--secondary","name":"Secondary","title":"UI/Button","importPath":"./src/stories/Buttons.stories.jsx","tags":["dev","test"]},"ui-button--accent":{"type":"story","id":"ui-button--accent","name":"Accent","title":"UI/Button","importPath":"./src/stories/Buttons.stories.jsx","tags":["dev","test"]},"ui-button--disabled":{"type":"story","id":"ui-button--disabled","name":"Disabled","title":"UI/Button","importPath":"./src/stories/Buttons.stories.jsx","tags":["dev","test"]},"ui-button--all-variants":{"type":"story","id":"ui-button--all-variants","name":"All Variants","title":"UI/Button","importPath":"./src/stories/Buttons.stories.jsx","tags":["dev","test"]},"ui-cards--default-card":{"type":"story","id":"ui-cards--default-card","name":"Default Card","title":"UI/Cards","importPath":"./src/stories/Cards.stories.jsx","tags":["dev","test"]},"ui-cards--glass-card":{"type":"story","id":"ui-cards--glass-card","name":"Glass Card","title":"UI/Cards","importPath":"./src/stories/Cards.stories.jsx","tags":["dev","test"]},"ui-cards--habit-not-completed":{"type":"story","id":"ui-cards--habit-not-completed","name":"Habit Not Completed","title":"UI/Cards","importPath":"./src/stories/Cards.stories.jsx","tags":["dev","test"]},"ui-cards--habit-completed":{"type":"story","id":"ui-cards--habit-completed","name":"Habit Completed","title":"UI/Cards","importPath":"./src/stories/Cards.stories.jsx","tags":["dev","test"]},"ui-cards--task-normal":{"type":"story","id":"ui-cards--task-normal","name":"Task Normal","title":"UI/Cards","importPath":"./src/stories/Cards.stories.jsx","tags":["dev","test"]},"ui-cards--task-high-priority":{"type":"story","id":"ui-cards--task-high-priority","name":"Task High Priority","title":"UI/Cards","importPath":"./src/stories/Cards.stories.jsx","tags":["dev","test"]},"ui-cards--task-done":{"type":"story","id":"ui-cards--task-done","name":"Task Done","title":"UI/Cards","importPath":"./src/stories/Cards.stories.jsx","tags":["dev","test"]},"components-navigation--default":{"type":"story","id":"components-navigation--default","name":"Default","title":"Components/Navigation","importPath":"./src/stories/Navigation.stories.jsx","componentPath":"./src/components/Navigation.jsx","tags":["dev","test"]},"components-navigation--on-habits-page":{"type":"story","id":"components-navigation--on-habits-page","name":"On Habits Page","title":"Components/Navigation","importPath":"./src/stories/Navigation.stories.jsx","componentPath":"./src/components/Navigation.jsx","tags":["dev","test"]}}}
|
||||
BIN
storybook-static/nunito-sans-bold-italic.woff2
Normal file
BIN
storybook-static/nunito-sans-bold-italic.woff2
Normal file
Binary file not shown.
BIN
storybook-static/nunito-sans-bold.woff2
Normal file
BIN
storybook-static/nunito-sans-bold.woff2
Normal file
Binary file not shown.
BIN
storybook-static/nunito-sans-italic.woff2
Normal file
BIN
storybook-static/nunito-sans-italic.woff2
Normal file
Binary file not shown.
BIN
storybook-static/nunito-sans-regular.woff2
Normal file
BIN
storybook-static/nunito-sans-regular.woff2
Normal file
Binary file not shown.
1
storybook-static/project.json
Normal file
1
storybook-static/project.json
Normal file
@@ -0,0 +1 @@
|
||||
{"generatedAt":1772285649405,"userSince":1772285647831,"hasCustomBabel":false,"hasCustomWebpack":false,"hasStaticDirs":false,"hasStorybookEslint":false,"refCount":0,"testPackages":{},"hasRouterPackage":true,"packageManager":{"type":"npm","agent":"npm"},"preview":{"usesGlobals":false},"framework":{"name":"@storybook/react-vite","options":{}},"builder":"@storybook/builder-vite","renderer":"@storybook/react","storybookVersion":"8.6.17","storybookVersionSpecifier":"^8.5.0","language":"javascript","storybookPackages":{"@storybook/react":{"version":"8.6.17"},"@storybook/react-vite":{"version":"8.6.17"},"@storybook/blocks":{"version":"8.6.14"},"storybook":{"version":"8.6.17"}},"addons":{"@storybook/addon-essentials":{"version":"8.6.14"},"@storybook/addon-themes":{"version":"8.6.17"}}}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
245
storybook-static/sb-addons/essentials-docs-3/manager-bundle.js
Normal file
245
storybook-static/sb-addons/essentials-docs-3/manager-bundle.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
try{
|
||||
(()=>{var T=__STORYBOOK_API__,{ActiveTabs:h,Consumer:g,ManagerContext:f,Provider:v,RequestResponseError:A,addons:n,combineParameters:x,controlOrMetaKey:P,controlOrMetaSymbol:k,eventMatchesShortcut:M,eventToShortcut:R,experimental_MockUniversalStore:C,experimental_UniversalStore:U,experimental_requestResponse:w,experimental_useUniversalStore:B,isMacLike:E,isShortcutTaken:I,keyToSymbol:K,merge:N,mockChannel:G,optionOrAltSymbol:L,shortcutMatchesShortcut:Y,shortcutToHumanString:q,types:D,useAddonState:F,useArgTypes:H,useArgs:j,useChannel:V,useGlobalTypes:z,useGlobals:J,useParameter:Q,useSharedState:W,useStoryPrepared:X,useStorybookApi:Z,useStorybookState:$}=__STORYBOOK_API__;var S=(()=>{let e;return typeof window<"u"?e=window:typeof globalThis<"u"?e=globalThis:typeof window<"u"?e=window:typeof self<"u"?e=self:e={},e})(),c="tag-filters",p="static-filter";n.register(c,e=>{let u=Object.entries(S.TAGS_OPTIONS??{}).reduce((t,r)=>{let[o,i]=r;return i.excludeFromSidebar&&(t[o]=!0),t},{});e.experimental_setFilter(p,t=>{let r=t.tags??[];return(r.includes("dev")||t.type==="docs")&&r.filter(o=>u[o]).length===0})});})();
|
||||
}catch(e){ console.error("[Storybook] One of your manager-entries failed: " + import.meta.url, e); }
|
||||
3
storybook-static/sb-addons/themes-9/manager-bundle.js
Normal file
3
storybook-static/sb-addons/themes-9/manager-bundle.js
Normal file
File diff suppressed because one or more lines are too long
1
storybook-static/sb-common-assets/favicon.svg
Normal file
1
storybook-static/sb-common-assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svgjs="http://svgjs.com/svgjs" xmlns:xlink="http://www.w3.org/1999/xlink" width="164" height="164" version="1.1"><svg xmlns="http://www.w3.org/2000/svg" width="164" height="164" fill="none" viewBox="0 0 164 164"><path fill="#FF4785" d="M22.467 147.762 17.5 15.402a8.062 8.062 0 0 1 7.553-8.35L137.637.016a8.061 8.061 0 0 1 8.565 8.047v144.23a8.063 8.063 0 0 1-8.424 8.054l-107.615-4.833a8.062 8.062 0 0 1-7.695-7.752Z"/><path fill="#fff" fill-rule="evenodd" d="m128.785.57-15.495.968-.755 18.172a1.203 1.203 0 0 0 1.928 1.008l7.06-5.354 5.962 4.697a1.202 1.202 0 0 0 1.946-.987L128.785.569Zm-12.059 60.856c-2.836 2.203-23.965 3.707-23.965.57.447-11.969-4.912-12.494-7.889-12.494-2.828 0-7.59.855-7.59 7.267 0 6.534 6.96 10.223 15.13 14.553 11.607 6.15 25.654 13.594 25.654 32.326 0 17.953-14.588 27.871-33.194 27.871-19.201 0-35.981-7.769-34.086-34.702.744-3.163 25.156-2.411 25.156 0-.298 11.114 2.232 14.383 8.633 14.383 4.912 0 7.144-2.708 7.144-7.267 0-6.9-7.252-10.973-15.595-15.657C64.827 81.933 51.53 74.468 51.53 57.34c0-17.098 11.76-28.497 32.747-28.497 20.988 0 32.449 11.224 32.449 32.584Z" clip-rule="evenodd"/></svg><style>@media (prefers-color-scheme:light){:root{filter:none}}</style></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
BIN
storybook-static/sb-common-assets/nunito-sans-bold-italic.woff2
Normal file
BIN
storybook-static/sb-common-assets/nunito-sans-bold-italic.woff2
Normal file
Binary file not shown.
BIN
storybook-static/sb-common-assets/nunito-sans-bold.woff2
Normal file
BIN
storybook-static/sb-common-assets/nunito-sans-bold.woff2
Normal file
Binary file not shown.
BIN
storybook-static/sb-common-assets/nunito-sans-italic.woff2
Normal file
BIN
storybook-static/sb-common-assets/nunito-sans-italic.woff2
Normal file
Binary file not shown.
BIN
storybook-static/sb-common-assets/nunito-sans-regular.woff2
Normal file
BIN
storybook-static/sb-common-assets/nunito-sans-regular.woff2
Normal file
Binary file not shown.
1052
storybook-static/sb-manager/globals-module-info.js
Normal file
1052
storybook-static/sb-manager/globals-module-info.js
Normal file
File diff suppressed because it is too large
Load Diff
41775
storybook-static/sb-manager/globals-runtime.js
Normal file
41775
storybook-static/sb-manager/globals-runtime.js
Normal file
File diff suppressed because one or more lines are too long
48
storybook-static/sb-manager/globals.js
Normal file
48
storybook-static/sb-manager/globals.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import ESM_COMPAT_Module from "node:module";
|
||||
import { fileURLToPath as ESM_COMPAT_fileURLToPath } from 'node:url';
|
||||
import { dirname as ESM_COMPAT_dirname } from 'node:path';
|
||||
const __filename = ESM_COMPAT_fileURLToPath(import.meta.url);
|
||||
const __dirname = ESM_COMPAT_dirname(__filename);
|
||||
const require = ESM_COMPAT_Module.createRequire(import.meta.url);
|
||||
|
||||
// src/manager/globals/globals.ts
|
||||
var _ = {
|
||||
react: "__REACT__",
|
||||
"react-dom": "__REACT_DOM__",
|
||||
"react-dom/client": "__REACT_DOM_CLIENT__",
|
||||
"@storybook/icons": "__STORYBOOK_ICONS__",
|
||||
"storybook/internal/manager-api": "__STORYBOOK_API__",
|
||||
"@storybook/manager-api": "__STORYBOOK_API__",
|
||||
"@storybook/core/manager-api": "__STORYBOOK_API__",
|
||||
"storybook/internal/components": "__STORYBOOK_COMPONENTS__",
|
||||
"@storybook/components": "__STORYBOOK_COMPONENTS__",
|
||||
"@storybook/core/components": "__STORYBOOK_COMPONENTS__",
|
||||
"storybook/internal/channels": "__STORYBOOK_CHANNELS__",
|
||||
"@storybook/channels": "__STORYBOOK_CHANNELS__",
|
||||
"@storybook/core/channels": "__STORYBOOK_CHANNELS__",
|
||||
"storybook/internal/core-errors": "__STORYBOOK_CORE_EVENTS__",
|
||||
"@storybook/core-events": "__STORYBOOK_CORE_EVENTS__",
|
||||
"@storybook/core/core-events": "__STORYBOOK_CORE_EVENTS__",
|
||||
"storybook/internal/manager-errors": "__STORYBOOK_CORE_EVENTS_MANAGER_ERRORS__",
|
||||
"@storybook/core-events/manager-errors": "__STORYBOOK_CORE_EVENTS_MANAGER_ERRORS__",
|
||||
"@storybook/core/manager-errors": "__STORYBOOK_CORE_EVENTS_MANAGER_ERRORS__",
|
||||
"storybook/internal/router": "__STORYBOOK_ROUTER__",
|
||||
"@storybook/router": "__STORYBOOK_ROUTER__",
|
||||
"@storybook/core/router": "__STORYBOOK_ROUTER__",
|
||||
"storybook/internal/theming": "__STORYBOOK_THEMING__",
|
||||
"@storybook/theming": "__STORYBOOK_THEMING__",
|
||||
"@storybook/core/theming": "__STORYBOOK_THEMING__",
|
||||
"storybook/internal/theming/create": "__STORYBOOK_THEMING_CREATE__",
|
||||
"@storybook/theming/create": "__STORYBOOK_THEMING_CREATE__",
|
||||
"@storybook/core/theming/create": "__STORYBOOK_THEMING_CREATE__",
|
||||
"storybook/internal/client-logger": "__STORYBOOK_CLIENT_LOGGER__",
|
||||
"@storybook/client-logger": "__STORYBOOK_CLIENT_LOGGER__",
|
||||
"@storybook/core/client-logger": "__STORYBOOK_CLIENT_LOGGER__",
|
||||
"storybook/internal/types": "__STORYBOOK_TYPES__",
|
||||
"@storybook/types": "__STORYBOOK_TYPES__",
|
||||
"@storybook/core/types": "__STORYBOOK_TYPES__"
|
||||
}, o = Object.keys(_);
|
||||
export {
|
||||
o as globalPackages,
|
||||
_ as globalsNameReferenceMap
|
||||
};
|
||||
12048
storybook-static/sb-manager/runtime.js
Normal file
12048
storybook-static/sb-manager/runtime.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ export default {
|
||||
'./index.html',
|
||||
'./src/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
|
||||
Reference in New Issue
Block a user