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": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"storybook": "storybook dev -p 6006",
|
||||||
|
"build-storybook": "storybook build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -18,7 +20,8 @@
|
|||||||
"date-fns": "^3.3.1",
|
"date-fns": "^3.3.1",
|
||||||
"lucide-react": "^0.312.0",
|
"lucide-react": "^0.312.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"framer-motion": "^11.0.3"
|
"framer-motion": "^11.0.3",
|
||||||
|
"recharts": "^2.12.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.48",
|
"@types/react": "^18.2.48",
|
||||||
@@ -27,6 +30,12 @@
|
|||||||
"autoprefixer": "^10.4.17",
|
"autoprefixer": "^10.4.17",
|
||||||
"postcss": "^8.4.33",
|
"postcss": "^8.4.33",
|
||||||
"tailwindcss": "^3.4.1",
|
"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 Home from "./pages/Home"
|
||||||
import Habits from "./pages/Habits"
|
import Habits from "./pages/Habits"
|
||||||
import Tasks from "./pages/Tasks"
|
import Tasks from "./pages/Tasks"
|
||||||
|
import Savings from "./pages/Savings"
|
||||||
import VerifyEmail from "./pages/VerifyEmail"
|
import VerifyEmail from "./pages/VerifyEmail"
|
||||||
import ResetPassword from "./pages/ResetPassword"
|
import ResetPassword from "./pages/ResetPassword"
|
||||||
import ForgotPassword from "./pages/ForgotPassword"
|
import ForgotPassword from "./pages/ForgotPassword"
|
||||||
@@ -107,6 +108,14 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/savings"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Savings />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/stats"
|
path="/stats"
|
||||||
element={
|
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),
|
getStats: () => api.get('/habits/stats').then(r => r.data),
|
||||||
getHabitStats: (id) => api.get(`/habits/${id}/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 { useState } from "react"
|
||||||
import { motion, AnimatePresence } from "framer-motion"
|
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 { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { habitsApi } from "../api/habits"
|
import { habitsApi } from "../api/habits"
|
||||||
|
import { format } from "date-fns"
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
|
|
||||||
const COLORS = [
|
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: ["👥", "💬", "📞", "👪", "❤️"] },
|
||||||
{ 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 [icon, setIcon] = useState("✨")
|
||||||
const [frequency, setFrequency] = useState("daily")
|
const [frequency, setFrequency] = useState("daily")
|
||||||
const [targetDays, setTargetDays] = useState([1, 2, 3, 4, 5, 6, 7])
|
const [targetDays, setTargetDays] = useState([1, 2, 3, 4, 5, 6, 7])
|
||||||
|
const [intervalDays, setIntervalDays] = useState(2)
|
||||||
const [reminderTime, setReminderTime] = useState("")
|
const [reminderTime, setReminderTime] = useState("")
|
||||||
|
const [startDate, setStartDate] = useState(format(new Date(), "yyyy-MM-dd"))
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [showAllIcons, setShowAllIcons] = useState(false)
|
const [showAllIcons, setShowAllIcons] = useState(false)
|
||||||
|
|
||||||
@@ -64,7 +67,9 @@ export default function CreateHabitModal({ open, onClose }) {
|
|||||||
setIcon("✨")
|
setIcon("✨")
|
||||||
setFrequency("daily")
|
setFrequency("daily")
|
||||||
setTargetDays([1, 2, 3, 4, 5, 6, 7])
|
setTargetDays([1, 2, 3, 4, 5, 6, 7])
|
||||||
|
setIntervalDays(2)
|
||||||
setReminderTime("")
|
setReminderTime("")
|
||||||
|
setStartDate(format(new Date(), "yyyy-MM-dd"))
|
||||||
setError("")
|
setError("")
|
||||||
setShowAllIcons(false)
|
setShowAllIcons(false)
|
||||||
onClose()
|
onClose()
|
||||||
@@ -80,11 +85,19 @@ export default function CreateHabitModal({ open, onClose }) {
|
|||||||
setError("Выбери хотя бы один день недели")
|
setError("Выбери хотя бы один день недели")
|
||||||
return
|
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") {
|
if (frequency === "weekly") {
|
||||||
data.target_days = targetDays
|
data.target_days = targetDays
|
||||||
}
|
}
|
||||||
|
if (frequency === "interval") {
|
||||||
|
data.target_count = parseInt(intervalDays)
|
||||||
|
}
|
||||||
if (reminderTime) {
|
if (reminderTime) {
|
||||||
data.reminder_time = reminderTime
|
data.reminder_time = reminderTime
|
||||||
}
|
}
|
||||||
@@ -119,12 +132,12 @@ export default function CreateHabitModal({ open, onClose }) {
|
|||||||
exit={{ opacity: 0, y: 100 }}
|
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"
|
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="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 border-b border-gray-100 px-4 py-3 flex items-center justify-between">
|
<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>
|
<h2 className="text-lg font-semibold">Новая привычка</h2>
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
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} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -138,7 +151,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>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -152,7 +165,7 @@ export default function CreateHabitModal({ open, onClose }) {
|
|||||||
</div>
|
</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>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -165,7 +178,7 @@ export default function CreateHabitModal({ open, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -173,10 +186,10 @@ export default function CreateHabitModal({ open, onClose }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFrequency("daily")}
|
onClick={() => setFrequency("daily")}
|
||||||
className={clsx(
|
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"
|
frequency === "daily"
|
||||||
? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
|
? "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"
|
type="button"
|
||||||
onClick={() => setFrequency("weekly")}
|
onClick={() => setFrequency("weekly")}
|
||||||
className={clsx(
|
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"
|
frequency === "weekly"
|
||||||
? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
|
? "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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,7 +227,7 @@ export default function CreateHabitModal({ open, onClose }) {
|
|||||||
animate={{ opacity: 1, height: "auto" }}
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
exit={{ opacity: 0, height: 0 }}
|
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>
|
</label>
|
||||||
<div className="flex gap-1.5">
|
<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",
|
"flex-1 py-2 rounded-lg font-medium text-sm transition-all",
|
||||||
targetDays.includes(day.id)
|
targetDays.includes(day.id)
|
||||||
? "bg-primary-500 text-white shadow-md"
|
? "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}
|
{day.short}
|
||||||
@@ -225,12 +250,53 @@ export default function CreateHabitModal({ open, onClose }) {
|
|||||||
</motion.div>
|
</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>
|
<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>
|
</label>
|
||||||
<div className="relative">
|
<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
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
value={reminderTime}
|
value={reminderTime}
|
||||||
@@ -238,13 +304,13 @@ export default function CreateHabitModal({ open, onClose }) {
|
|||||||
className="input pl-10"
|
className="input pl-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 в указанное время
|
Получишь напоминание в Telegram в указанное время
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<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",
|
"w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all",
|
||||||
icon === ic
|
icon === ic
|
||||||
? "bg-primary-100 ring-2 ring-primary-500"
|
? "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}
|
{ic}
|
||||||
@@ -284,7 +350,7 @@ export default function CreateHabitModal({ open, onClose }) {
|
|||||||
>
|
>
|
||||||
{ICON_CATEGORIES.map((category) => (
|
{ICON_CATEGORIES.map((category) => (
|
||||||
<div key={category.name}>
|
<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">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{category.icons.map((ic) => (
|
{category.icons.map((ic) => (
|
||||||
<button
|
<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",
|
"w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all",
|
||||||
icon === ic
|
icon === ic
|
||||||
? "bg-primary-100 ring-2 ring-primary-500"
|
? "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}
|
{ic}
|
||||||
@@ -310,7 +376,7 @@ export default function CreateHabitModal({ open, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { motion, AnimatePresence } from "framer-motion"
|
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 { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { tasksApi } from "../api/tasks"
|
import { tasksApi } from "../api/tasks"
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
@@ -21,12 +21,19 @@ const ICON_CATEGORIES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const PRIORITIES = [
|
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: 1, label: "Низкий", color: "bg-blue-100 text-blue-700" },
|
||||||
{ value: 2, label: "Средний", color: "bg-yellow-100 text-yellow-700" },
|
{ value: 2, label: "Средний", color: "bg-yellow-100 text-yellow-700" },
|
||||||
{ value: 3, label: "Высокий", color: "bg-red-100 text-red-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 }) {
|
export default function CreateTaskModal({ open, onClose, defaultDueDate = null }) {
|
||||||
const today = format(new Date(), "yyyy-MM-dd")
|
const today = format(new Date(), "yyyy-MM-dd")
|
||||||
const tomorrow = format(addDays(new Date(), 1), "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 [error, setError] = useState("")
|
||||||
const [showAllIcons, setShowAllIcons] = 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()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
@@ -65,6 +78,10 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
|||||||
setReminderTime("")
|
setReminderTime("")
|
||||||
setError("")
|
setError("")
|
||||||
setShowAllIcons(false)
|
setShowAllIcons(false)
|
||||||
|
setIsRecurring(false)
|
||||||
|
setRecurrenceType("daily")
|
||||||
|
setRecurrenceInterval(1)
|
||||||
|
setRecurrenceEndDate("")
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +92,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mutation.mutate({
|
const data = {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
color,
|
color,
|
||||||
@@ -83,7 +100,16 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
|||||||
due_date: dueDate || null,
|
due_date: dueDate || null,
|
||||||
priority,
|
priority,
|
||||||
reminder_time: reminderTime || null,
|
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 = ["📋", "📝", "✅", "🎯", "💼", "🏠", "💰", "📞"]
|
const popularIcons = ["📋", "📝", "✅", "🎯", "💼", "🏠", "💰", "📞"]
|
||||||
@@ -105,12 +131,12 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
|||||||
exit={{ opacity: 0, y: 100 }}
|
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"
|
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="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 border-b border-gray-100 px-4 py-3 flex items-center justify-between">
|
<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>
|
<h2 className="text-lg font-semibold">Новая задача</h2>
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
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} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -124,7 +150,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>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -138,7 +164,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
|||||||
</div>
|
</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>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -150,7 +176,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
</label>
|
||||||
<div className="flex gap-2 mb-2">
|
<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",
|
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||||
dueDate === today
|
dueDate === today
|
||||||
? "bg-primary-500 text-white"
|
? "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",
|
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||||
dueDate === tomorrow
|
dueDate === tomorrow
|
||||||
? "bg-primary-500 text-white"
|
? "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",
|
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||||
!dueDate
|
!dueDate
|
||||||
? "bg-primary-500 text-white"
|
? "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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<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
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={dueDate}
|
value={dueDate}
|
||||||
@@ -202,12 +228,92 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<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>
|
</label>
|
||||||
<div className="relative">
|
<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
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
value={reminderTime}
|
value={reminderTime}
|
||||||
@@ -215,13 +321,13 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
|||||||
className="input pl-10"
|
className="input pl-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 в указанное время
|
Получишь напоминание в Telegram в указанное время
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
</label>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<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",
|
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||||
priority === p.value
|
priority === p.value
|
||||||
? p.color + " ring-2 ring-offset-1 ring-gray-400"
|
? 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}
|
{p.label}
|
||||||
@@ -244,7 +350,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<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",
|
"w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all",
|
||||||
icon === ic
|
icon === ic
|
||||||
? "bg-primary-100 ring-2 ring-primary-500"
|
? "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}
|
{ic}
|
||||||
@@ -284,7 +390,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
|||||||
>
|
>
|
||||||
{ICON_CATEGORIES.map((category) => (
|
{ICON_CATEGORIES.map((category) => (
|
||||||
<div key={category.name}>
|
<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">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{category.icons.map((ic) => (
|
{category.icons.map((ic) => (
|
||||||
<button
|
<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",
|
"w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all",
|
||||||
icon === ic
|
icon === ic
|
||||||
? "bg-primary-100 ring-2 ring-primary-500"
|
? "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}
|
{ic}
|
||||||
@@ -310,7 +416,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { motion, AnimatePresence } from "framer-motion"
|
import { motion, AnimatePresence } from "framer-motion"
|
||||||
import { X, Trash2, ChevronDown, ChevronUp, Clock } from "lucide-react"
|
import { X, Trash2, ChevronDown, ChevronUp, Clock, Calendar, Snowflake, Plus } from "lucide-react"
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query"
|
||||||
import { habitsApi } from "../api/habits"
|
import { habitsApi } from "../api/habits"
|
||||||
|
import { format, parseISO, isBefore, isAfter, startOfDay } from "date-fns"
|
||||||
|
import { ru } from "date-fns/locale"
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
|
|
||||||
const COLORS = [
|
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: ["👥", "💬", "📞", "👪", "❤️"] },
|
||||||
{ 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 [icon, setIcon] = useState("✨")
|
||||||
const [frequency, setFrequency] = useState("daily")
|
const [frequency, setFrequency] = useState("daily")
|
||||||
const [targetDays, setTargetDays] = useState([1, 2, 3, 4, 5, 6, 7])
|
const [targetDays, setTargetDays] = useState([1, 2, 3, 4, 5, 6, 7])
|
||||||
|
const [intervalDays, setIntervalDays] = useState(3)
|
||||||
const [reminderTime, setReminderTime] = useState("")
|
const [reminderTime, setReminderTime] = useState("")
|
||||||
|
const [startDate, setStartDate] = useState("")
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
const [showAllIcons, setShowAllIcons] = 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()
|
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(() => {
|
useEffect(() => {
|
||||||
if (habit && open) {
|
if (habit && open) {
|
||||||
setName(habit.name || "")
|
setName(habit.name || "")
|
||||||
@@ -54,10 +70,23 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
|||||||
setIcon(habit.icon || "✨")
|
setIcon(habit.icon || "✨")
|
||||||
setFrequency(habit.frequency || "daily")
|
setFrequency(habit.frequency || "daily")
|
||||||
setTargetDays(habit.target_days || [1, 2, 3, 4, 5, 6, 7])
|
setTargetDays(habit.target_days || [1, 2, 3, 4, 5, 6, 7])
|
||||||
|
setIntervalDays(habit.target_count || 3)
|
||||||
setReminderTime(habit.reminder_time || "")
|
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("")
|
setError("")
|
||||||
setShowDeleteConfirm(false)
|
setShowDeleteConfirm(false)
|
||||||
setShowAllIcons(false)
|
setShowAllIcons(false)
|
||||||
|
setShowFreezes(false)
|
||||||
|
setShowAddFreeze(false)
|
||||||
|
setFreezeStart("")
|
||||||
|
setFreezeEnd("")
|
||||||
|
setFreezeReason("")
|
||||||
}
|
}
|
||||||
}, [habit, open])
|
}, [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 = () => {
|
const handleClose = () => {
|
||||||
setError("")
|
setError("")
|
||||||
setShowDeleteConfirm(false)
|
setShowDeleteConfirm(false)
|
||||||
setShowAllIcons(false)
|
setShowAllIcons(false)
|
||||||
|
setShowFreezes(false)
|
||||||
|
setShowAddFreeze(false)
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,11 +156,19 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
|||||||
setError("Выбери хотя бы один день недели")
|
setError("Выбери хотя бы один день недели")
|
||||||
return
|
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") {
|
if (frequency === "weekly") {
|
||||||
data.target_days = targetDays
|
data.target_days = targetDays
|
||||||
}
|
}
|
||||||
|
if (frequency === "interval") {
|
||||||
|
data.target_count = parseInt(intervalDays)
|
||||||
|
}
|
||||||
data.reminder_time = reminderTime || null
|
data.reminder_time = reminderTime || null
|
||||||
|
|
||||||
updateMutation.mutate(data)
|
updateMutation.mutate(data)
|
||||||
@@ -116,6 +178,23 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
|||||||
deleteMutation.mutate()
|
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) => {
|
const toggleDay = (dayId) => {
|
||||||
setTargetDays(prev =>
|
setTargetDays(prev =>
|
||||||
prev.includes(dayId)
|
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 = ["✨", "💪", "📚", "🏃", "💧", "🧘", "💤", "🎯", "✏️", "🍎"]
|
const popularIcons = ["✨", "💪", "📚", "🏃", "💧", "🧘", "💤", "🎯", "✏️", "🍎"]
|
||||||
|
|
||||||
if (!habit) return null
|
if (!habit) return null
|
||||||
@@ -145,12 +228,12 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
|||||||
exit={{ opacity: 0, y: 100 }}
|
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"
|
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="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 border-b border-gray-100 px-4 py-3 flex items-center justify-between">
|
<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>
|
<h2 className="text-lg font-semibold">Редактировать привычку</h2>
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
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} />
|
<X size={20} />
|
||||||
</button>
|
</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">
|
<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" />
|
<Trash2 className="w-8 h-8 text-red-500" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Удалить привычку?</h3>
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Удалить привычку?</h3>
|
||||||
<p className="text-gray-500 mb-6">
|
<p className="text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-6">
|
||||||
Привычка "{habit.name}" и вся её история будут удалены безвозвратно.
|
Привычка "{habit.name}" и вся её история будут удалены безвозвратно.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDeleteConfirm(false)}
|
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>
|
</button>
|
||||||
@@ -190,7 +273,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>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -203,7 +286,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
|||||||
</div>
|
</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>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -216,7 +299,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -224,10 +307,10 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFrequency("daily")}
|
onClick={() => setFrequency("daily")}
|
||||||
className={clsx(
|
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"
|
frequency === "daily"
|
||||||
? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
|
? "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"
|
type="button"
|
||||||
onClick={() => setFrequency("weekly")}
|
onClick={() => setFrequency("weekly")}
|
||||||
className={clsx(
|
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"
|
frequency === "weekly"
|
||||||
? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
|
? "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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,7 +348,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
|||||||
animate={{ opacity: 1, height: "auto" }}
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
exit={{ opacity: 0, height: 0 }}
|
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>
|
</label>
|
||||||
<div className="flex gap-1.5">
|
<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",
|
"flex-1 py-2 rounded-lg font-medium text-sm transition-all",
|
||||||
targetDays.includes(day.id)
|
targetDays.includes(day.id)
|
||||||
? "bg-primary-500 text-white shadow-md"
|
? "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}
|
{day.short}
|
||||||
@@ -276,12 +371,53 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
|||||||
</motion.div>
|
</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>
|
<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>
|
</label>
|
||||||
<div className="relative">
|
<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
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
value={reminderTime}
|
value={reminderTime}
|
||||||
@@ -289,13 +425,180 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
|||||||
className="input pl-10"
|
className="input pl-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 в указанное время
|
Получишь напоминание в Telegram в указанное время
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
<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>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<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",
|
"w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all",
|
||||||
icon === ic
|
icon === ic
|
||||||
? "bg-primary-100 ring-2 ring-primary-500"
|
? "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}
|
{ic}
|
||||||
@@ -335,7 +638,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
|||||||
>
|
>
|
||||||
{ICON_CATEGORIES.map((category) => (
|
{ICON_CATEGORIES.map((category) => (
|
||||||
<div key={category.name}>
|
<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">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{category.icons.map((ic) => (
|
{category.icons.map((ic) => (
|
||||||
<button
|
<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",
|
"w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all",
|
||||||
icon === ic
|
icon === ic
|
||||||
? "bg-primary-100 ring-2 ring-primary-500"
|
? "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}
|
{ic}
|
||||||
@@ -361,7 +664,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { motion, AnimatePresence } from "framer-motion"
|
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 { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { tasksApi } from "../api/tasks"
|
import { tasksApi } from "../api/tasks"
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
@@ -21,12 +21,19 @@ const ICON_CATEGORIES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const PRIORITIES = [
|
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: 1, label: "Низкий", color: "bg-blue-100 text-blue-700" },
|
||||||
{ value: 2, label: "Средний", color: "bg-yellow-100 text-yellow-700" },
|
{ value: 2, label: "Средний", color: "bg-yellow-100 text-yellow-700" },
|
||||||
{ value: 3, label: "Высокий", color: "bg-red-100 text-red-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 }) {
|
export default function EditTaskModal({ open, onClose, task }) {
|
||||||
const today = format(new Date(), "yyyy-MM-dd")
|
const today = format(new Date(), "yyyy-MM-dd")
|
||||||
const tomorrow = format(addDays(new Date(), 1), "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 [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
const [showAllIcons, setShowAllIcons] = 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()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -53,6 +66,10 @@ export default function EditTaskModal({ open, onClose, task }) {
|
|||||||
setDueDate(task.due_date || "")
|
setDueDate(task.due_date || "")
|
||||||
setPriority(task.priority || 0)
|
setPriority(task.priority || 0)
|
||||||
setReminderTime(task.reminder_time || "")
|
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("")
|
setError("")
|
||||||
setShowDeleteConfirm(false)
|
setShowDeleteConfirm(false)
|
||||||
setShowAllIcons(false)
|
setShowAllIcons(false)
|
||||||
@@ -97,7 +114,7 @@ export default function EditTaskModal({ open, onClose, task }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updateMutation.mutate({
|
const data = {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
color,
|
color,
|
||||||
@@ -105,11 +122,13 @@ export default function EditTaskModal({ open, onClose, task }) {
|
|||||||
due_date: dueDate || null,
|
due_date: dueDate || null,
|
||||||
priority,
|
priority,
|
||||||
reminder_time: reminderTime || null,
|
reminder_time: reminderTime || null,
|
||||||
})
|
is_recurring: isRecurring,
|
||||||
}
|
recurrence_type: isRecurring ? recurrenceType : null,
|
||||||
|
recurrence_interval: isRecurring && recurrenceType === "custom" ? recurrenceInterval : 1,
|
||||||
|
recurrence_end_date: isRecurring && recurrenceEndDate ? recurrenceEndDate : null,
|
||||||
|
}
|
||||||
|
|
||||||
const handleDelete = () => {
|
updateMutation.mutate(data)
|
||||||
deleteMutation.mutate()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const popularIcons = ["📋", "📝", "✅", "🎯", "💼", "🏠", "💰", "📞"]
|
const popularIcons = ["📋", "📝", "✅", "🎯", "💼", "🏠", "💰", "📞"]
|
||||||
@@ -133,275 +152,348 @@ export default function EditTaskModal({ open, onClose, task }) {
|
|||||||
exit={{ opacity: 0, y: 100 }}
|
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"
|
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="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 border-b border-gray-100 px-4 py-3 flex items-center justify-between">
|
<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>
|
<h2 className="text-lg font-semibold">Редактировать задачу</h2>
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
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} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showDeleteConfirm ? (
|
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||||
<div className="p-6 text-center">
|
{error && (
|
||||||
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
|
<div className="p-3 rounded-xl bg-red-50 text-red-600 text-sm">
|
||||||
<Trash2 className="w-8 h-8 text-red-500" />
|
{error}
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Удалить задачу?</h3>
|
|
||||||
<p className="text-gray-500 mb-6">
|
|
||||||
Задача "{task.title}" будет удалена безвозвратно.
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowDeleteConfirm(false)}
|
|
||||||
className="flex-1 btn bg-gray-100 text-gray-700 hover:bg-gray-200"
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
className="flex-1 btn bg-red-500 text-white hover:bg-red-600"
|
|
||||||
>
|
|
||||||
{deleteMutation.isPending ? "Удаляем..." : "Удалить"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
</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>
|
<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>
|
</label>
|
||||||
<input
|
<textarea
|
||||||
type="text"
|
value={description}
|
||||||
value={title}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
className="input min-h-[80px] resize-none"
|
||||||
className="input"
|
placeholder="Подробности задачи..."
|
||||||
placeholder="Что нужно сделать?"
|
/>
|
||||||
/>
|
</div>
|
||||||
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowAllIcons(!showAllIcons)}
|
onClick={() => setDueDate(today)}
|
||||||
className="mt-2 flex items-center gap-1 text-sm text-primary-600 hover:text-primary-700"
|
className={clsx(
|
||||||
>
|
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||||
{showAllIcons ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
dueDate === today
|
||||||
{showAllIcons ? "Скрыть" : "Все иконки"}
|
? "bg-primary-500 text-white"
|
||||||
</button>
|
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{showAllIcons && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, height: 0 }}
|
|
||||||
animate={{ opacity: 1, height: "auto" }}
|
|
||||||
exit={{ opacity: 0, height: 0 }}
|
|
||||||
className="mt-3 space-y-3"
|
|
||||||
>
|
|
||||||
{ICON_CATEGORIES.map((category) => (
|
|
||||||
<div key={category.name}>
|
|
||||||
<p className="text-xs text-gray-500 mb-1.5">{category.name}</p>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{category.icons.map((ic) => (
|
|
||||||
<button
|
|
||||||
key={ic}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIcon(ic)}
|
|
||||||
className={clsx(
|
|
||||||
"w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all",
|
|
||||||
icon === ic
|
|
||||||
? "bg-primary-100 ring-2 ring-primary-500"
|
|
||||||
: "bg-gray-100 hover:bg-gray-200"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{ic}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Цвет
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{COLORS.map((c) => (
|
|
||||||
<button
|
|
||||||
key={c}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setColor(c)}
|
|
||||||
className={clsx(
|
|
||||||
"w-8 h-8 rounded-full transition-all",
|
|
||||||
color === c ? "ring-2 ring-offset-2 ring-gray-400 scale-110" : ""
|
|
||||||
)}
|
|
||||||
style={{ backgroundColor: c }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-2 space-y-3">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={updateMutation.isPending}
|
|
||||||
className="btn btn-primary w-full"
|
|
||||||
>
|
>
|
||||||
{updateMutation.isPending ? "Сохраняем..." : "Сохранить изменения"}
|
Сегодня
|
||||||
</button>
|
</button>
|
||||||
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
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} />
|
<Trash2 size={18} />
|
||||||
Удалить задачу
|
Удалить задачу
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</motion.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 { 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"
|
import clsx from "clsx"
|
||||||
|
|
||||||
export default function Navigation() {
|
export default function Navigation() {
|
||||||
@@ -8,12 +8,13 @@ export default function Navigation() {
|
|||||||
{ to: "/habits", icon: ListChecks, label: "Привычки" },
|
{ to: "/habits", icon: ListChecks, label: "Привычки" },
|
||||||
{ to: "/tasks", icon: CheckSquare, label: "Задачи" },
|
{ to: "/tasks", icon: CheckSquare, label: "Задачи" },
|
||||||
{ to: "/stats", icon: BarChart3, label: "Статистика" },
|
{ to: "/stats", icon: BarChart3, label: "Статистика" },
|
||||||
|
{ to: "/savings", icon: PiggyBank, label: "Накопления" },
|
||||||
{ to: "/settings", icon: Settings, label: "Настройки" },
|
{ to: "/settings", icon: Settings, label: "Настройки" },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="fixed bottom-0 left-0 right-0 bg-white/80 backdrop-blur-xl border-t border-gray-100 z-50">
|
<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-4">
|
<div className="max-w-lg mx-auto px-2">
|
||||||
<div className="flex items-center justify-around py-2">
|
<div className="flex items-center justify-around py-2">
|
||||||
{navItems.map(({ to, icon: Icon, label }) => (
|
{navItems.map(({ to, icon: Icon, label }) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -21,15 +22,15 @@ export default function Navigation() {
|
|||||||
to={to}
|
to={to}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
clsx(
|
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
|
isActive
|
||||||
? "text-primary-600 bg-primary-50"
|
? "text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/30"
|
||||||
: "text-gray-400 hover:text-gray-600"
|
: "text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Icon size={20} />
|
<Icon size={18} />
|
||||||
<span className="text-xs font-medium">{label}</span>
|
<span className="text-[10px] font-medium">{label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 {
|
body {
|
||||||
@apply bg-surface-50 text-gray-900 antialiased;
|
@apply bg-surface-50 text-gray-900 antialiased;
|
||||||
|
@apply dark:bg-gray-950 dark:text-gray-100;
|
||||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
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 hover:from-primary-700 hover:to-primary-800;
|
||||||
@apply focus:ring-primary-500;
|
@apply focus:ring-primary-500;
|
||||||
@apply shadow-lg shadow-primary-500/25;
|
@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 {
|
.btn-secondary {
|
||||||
@apply bg-white text-gray-700 border border-gray-200;
|
@apply bg-white text-gray-700 border border-gray-200;
|
||||||
@apply hover:bg-gray-50 hover:border-gray-300;
|
@apply hover:bg-gray-50 hover:border-gray-300;
|
||||||
@apply focus:ring-gray-500;
|
@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 {
|
.btn-accent {
|
||||||
@@ -47,16 +53,21 @@
|
|||||||
@apply w-full px-4 py-3.5 rounded-2xl border border-gray-200 bg-white;
|
@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 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 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 {
|
.card {
|
||||||
@apply bg-white rounded-3xl shadow-sm border border-gray-100/50;
|
@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 {
|
.card-glass {
|
||||||
@apply bg-white/70 backdrop-blur-xl rounded-3xl;
|
@apply bg-white/70 backdrop-blur-xl rounded-3xl;
|
||||||
@apply border border-white/20 shadow-lg;
|
@apply border border-white/20 shadow-lg;
|
||||||
|
@apply dark:bg-gray-900/70 dark:border-gray-700/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gradient-mesh {
|
.gradient-mesh {
|
||||||
@@ -65,6 +76,13 @@
|
|||||||
radial-gradient(at 80% 0%, rgba(247, 181, 56, 0.08) 0px, transparent 50%),
|
radial-gradient(at 80% 0%, rgba(247, 181, 56, 0.08) 0px, transparent 50%),
|
||||||
radial-gradient(at 0% 50%, rgba(20, 184, 166, 0.05) 0px, transparent 50%);
|
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 */
|
/* Custom scrollbar */
|
||||||
@@ -78,8 +96,10 @@
|
|||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
@apply bg-gray-300 rounded-full;
|
@apply bg-gray-300 rounded-full;
|
||||||
|
@apply dark:bg-gray-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
@apply bg-gray-400;
|
@apply bg-gray-400;
|
||||||
|
@apply dark:bg-gray-600;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { ThemeProvider } from './contexts/ThemeContext'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
@@ -17,9 +18,11 @@ const queryClient = new QueryClient({
|
|||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<ThemeProvider>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
</BrowserRouter>
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,11 +28,8 @@ export default function Habits() {
|
|||||||
enabled: showArchived,
|
enabled: showArchived,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Загружаем статистику для каждой привычки
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (habits.length > 0) {
|
if (habits.length > 0) loadStats()
|
||||||
loadStats()
|
|
||||||
}
|
|
||||||
}, [habits])
|
}, [habits])
|
||||||
|
|
||||||
const loadStats = async () => {
|
const loadStats = async () => {
|
||||||
@@ -41,9 +38,7 @@ export default function Habits() {
|
|||||||
try {
|
try {
|
||||||
const stats = await habitsApi.getHabitStats(habit.id)
|
const stats = await habitsApi.getHabitStats(habit.id)
|
||||||
statsMap[habit.id] = stats
|
statsMap[habit.id] = stats
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
console.error('Error loading stats for habit', habit.id, e)
|
|
||||||
}
|
|
||||||
}))
|
}))
|
||||||
setHabitStats(statsMap)
|
setHabitStats(statsMap)
|
||||||
}
|
}
|
||||||
@@ -63,9 +58,8 @@ export default function Habits() {
|
|||||||
const days = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
|
const days = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
|
||||||
return habit.target_days.map(d => days[d - 1]).join(', ')
|
return habit.target_days.map(d => days[d - 1]).join(', ')
|
||||||
}
|
}
|
||||||
if (habit.frequency === 'custom') {
|
if (habit.frequency === 'interval') return `Каждые ${habit.target_count} дн.`
|
||||||
return `Каждые ${habit.target_count} дн.`
|
if (habit.frequency === 'custom') return `Каждые ${habit.target_count} дн.`
|
||||||
}
|
|
||||||
return habit.frequency
|
return habit.frequency
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,17 +67,14 @@ export default function Habits() {
|
|||||||
const archivedList = habits.filter(h => h.is_archived)
|
const archivedList = habits.filter(h => h.is_archived)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-surface-50 gradient-mesh pb-24">
|
<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 backdrop-blur-xl border-b border-gray-100/50 sticky top-0 z-10">
|
<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="max-w-lg mx-auto px-4 py-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-display font-bold text-gray-900">Мои привычки</h1>
|
<h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">Мои привычки</h1>
|
||||||
<p className="text-sm text-gray-500">{activeHabits.length} активных</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">{activeHabits.length} активных</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onClick={() => setShowCreateModal(true)} className="btn btn-primary flex items-center gap-2">
|
||||||
onClick={() => setShowCreateModal(true)}
|
|
||||||
className="btn btn-primary flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Plus size={18} />
|
<Plus size={18} />
|
||||||
Новая
|
Новая
|
||||||
</button>
|
</button>
|
||||||
@@ -96,37 +87,29 @@ export default function Habits() {
|
|||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<div key={i} className="card p-5 animate-pulse">
|
<div key={i} className="card p-5 animate-pulse">
|
||||||
<div className="flex items-center gap-4">
|
<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="flex-1">
|
||||||
<div className="h-5 bg-gray-200 rounded-lg w-1/2 mb-2" />
|
<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 rounded-lg w-1/3" />
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/3" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : activeHabits.length === 0 && !showArchived ? (
|
) : activeHabits.length === 0 && !showArchived ? (
|
||||||
<motion.div
|
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="card p-10 text-center">
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
<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">
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
<Plus className="w-10 h-10 text-primary-600 dark:text-primary-400" />
|
||||||
className="card p-10 text-center"
|
|
||||||
>
|
|
||||||
<div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-primary-100 to-accent-100 flex items-center justify-center mx-auto mb-5">
|
|
||||||
<Plus className="w-10 h-10 text-primary-600" />
|
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-display font-bold text-gray-900 mb-2">Нет привычек</h3>
|
<h3 className="text-xl font-display font-bold text-gray-900 dark:text-white mb-2">Нет привычек</h3>
|
||||||
<p className="text-gray-500 mb-6">Создай свою первую привычку!</p>
|
<p className="text-gray-500 dark:text-gray-400 mb-6">Создай свою первую привычку!</p>
|
||||||
<button
|
<button onClick={() => setShowCreateModal(true)} className="btn btn-primary">
|
||||||
onClick={() => setShowCreateModal(true)}
|
|
||||||
className="btn btn-primary"
|
|
||||||
>
|
|
||||||
<Plus size={20} className="mr-2" />
|
<Plus size={20} className="mr-2" />
|
||||||
Создать привычку
|
Создать привычку
|
||||||
</button>
|
</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Активные привычки */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{activeHabits.map((habit, index) => (
|
{activeHabits.map((habit, index) => (
|
||||||
@@ -143,32 +126,17 @@ export default function Habits() {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Архивные привычки */}
|
|
||||||
{archivedList.length > 0 && (
|
{archivedList.length > 0 && (
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<button
|
<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">
|
||||||
onClick={() => setShowArchived(!showArchived)}
|
|
||||||
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 mb-4"
|
|
||||||
>
|
|
||||||
<Archive size={18} />
|
<Archive size={18} />
|
||||||
<span className="font-medium">Архив ({archivedList.length})</span>
|
<span className="font-medium">Архив ({archivedList.length})</span>
|
||||||
<ChevronRight
|
<ChevronRight size={18} className={clsx('transition-transform', showArchived && 'rotate-90')} />
|
||||||
size={18}
|
|
||||||
className={clsx(
|
|
||||||
'transition-transform',
|
|
||||||
showArchived && 'rotate-90'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showArchived && (
|
{showArchived && (
|
||||||
<motion.div
|
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} className="space-y-3">
|
||||||
initial={{ opacity: 0, height: 0 }}
|
|
||||||
animate={{ opacity: 1, height: 'auto' }}
|
|
||||||
exit={{ opacity: 0, height: 0 }}
|
|
||||||
className="space-y-3"
|
|
||||||
>
|
|
||||||
{archivedList.map((habit, index) => (
|
{archivedList.map((habit, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={habit.id}
|
key={habit.id}
|
||||||
@@ -178,21 +146,14 @@ export default function Habits() {
|
|||||||
className="card p-4 opacity-60"
|
className="card p-4 opacity-60"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div
|
<div className="w-12 h-12 rounded-xl flex items-center justify-center text-xl" style={{ backgroundColor: habit.color + '20' }}>
|
||||||
className="w-12 h-12 rounded-xl flex items-center justify-center text-xl"
|
|
||||||
style={{ backgroundColor: habit.color + '20' }}
|
|
||||||
>
|
|
||||||
{habit.icon || '✨'}
|
{habit.icon || '✨'}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-gray-600 truncate">{habit.name}</h3>
|
<h3 className="font-semibold text-gray-600 dark:text-gray-400 truncate">{habit.name}</h3>
|
||||||
<p className="text-sm text-gray-400">{getFrequencyLabel(habit)}</p>
|
<p className="text-sm text-gray-400 dark:text-gray-500">{getFrequencyLabel(habit)}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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="Восстановить">
|
||||||
onClick={() => archiveMutation.mutate({ id: habit.id, archived: false })}
|
|
||||||
className="p-2 text-gray-400 hover:text-green-500 hover:bg-green-50 rounded-xl transition-all"
|
|
||||||
title="Восстановить"
|
|
||||||
>
|
|
||||||
<ArchiveRestore size={20} />
|
<ArchiveRestore size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,17 +169,8 @@ export default function Habits() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Navigation />
|
<Navigation />
|
||||||
|
<CreateHabitModal open={showCreateModal} onClose={() => setShowCreateModal(false)} />
|
||||||
<CreateHabitModal
|
<EditHabitModal open={!!editingHabit} onClose={() => setEditingHabit(null)} habit={editingHabit} />
|
||||||
open={showCreateModal}
|
|
||||||
onClose={() => setShowCreateModal(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EditHabitModal
|
|
||||||
open={!!editingHabit}
|
|
||||||
onClose={() => setEditingHabit(null)}
|
|
||||||
habit={editingHabit}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -234,20 +186,14 @@ function HabitListItem({ habit, index, stats, frequencyLabel, onEdit, onArchive
|
|||||||
className="card p-4 cursor-pointer hover:shadow-lg transition-all"
|
className="card p-4 cursor-pointer hover:shadow-lg transition-all"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div
|
<div className="w-14 h-14 rounded-2xl flex items-center justify-center text-2xl flex-shrink-0" style={{ backgroundColor: habit.color + '15' }}>
|
||||||
className="w-14 h-14 rounded-2xl flex items-center justify-center text-2xl flex-shrink-0"
|
|
||||||
style={{ backgroundColor: habit.color + '15' }}
|
|
||||||
>
|
|
||||||
{habit.icon || '✨'}
|
{habit.icon || '✨'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<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">
|
<div className="flex items-center gap-3 mt-1">
|
||||||
<span
|
<span className="text-xs font-medium px-2 py-0.5 rounded-full" style={{ backgroundColor: habit.color + '15', color: habit.color }}>
|
||||||
className="text-xs font-medium px-2 py-0.5 rounded-full"
|
|
||||||
style={{ backgroundColor: habit.color + '15', color: habit.color }}
|
|
||||||
>
|
|
||||||
{frequencyLabel}
|
{frequencyLabel}
|
||||||
</span>
|
</span>
|
||||||
{stats && stats.current_streak > 0 && (
|
{stats && stats.current_streak > 0 && (
|
||||||
@@ -262,11 +208,11 @@ function HabitListItem({ habit, index, stats, frequencyLabel, onEdit, onArchive
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-sm font-semibold text-gray-900">{stats.this_month}</p>
|
<p className="text-sm font-semibold text-gray-900 dark:text-white">{stats.this_month}</p>
|
||||||
<p className="text-xs text-gray-400">в месяц</p>
|
<p className="text-xs text-gray-400 dark:text-gray-500">в месяц</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ChevronRight size={20} className="text-gray-300" />
|
<ChevronRight size={20} className="text-gray-300 dark:text-gray-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { Check, Flame, TrendingUp, Zap, Sparkles, Undo2, Plus, Calendar, AlertTriangle, LogOut } from 'lucide-react'
|
import { Check, Flame, TrendingUp, Zap, Sparkles, Undo2, Plus, Calendar, AlertTriangle, LogOut, Snowflake } from 'lucide-react'
|
||||||
import { format, startOfWeek, differenceInDays, parseISO, isToday, isTomorrow, isPast } from 'date-fns'
|
import { format, startOfWeek, differenceInDays, parseISO, isToday, isTomorrow, isPast, startOfDay, isBefore, isAfter } from 'date-fns'
|
||||||
import { ru } from 'date-fns/locale'
|
import { ru } from 'date-fns/locale'
|
||||||
import { habitsApi } from '../api/habits'
|
import { habitsApi } from '../api/habits'
|
||||||
import { tasksApi } from '../api/tasks'
|
import { tasksApi } from '../api/tasks'
|
||||||
import { useAuthStore } from '../store/auth'
|
import { useAuthStore } from '../store/auth'
|
||||||
import Navigation from '../components/Navigation'
|
import Navigation from '../components/Navigation'
|
||||||
import CreateTaskModal from '../components/CreateTaskModal'
|
import CreateTaskModal from '../components/CreateTaskModal'
|
||||||
|
import LogHabitModal from '../components/LogHabitModal'
|
||||||
import clsx from 'clsx'
|
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) {
|
function shouldShowToday(habit, lastLogDate, freezes) {
|
||||||
const today = new Date()
|
const today = startOfDay(new Date())
|
||||||
const dayOfWeek = today.getDay() || 7 // 1=Пн, 7=Вс (JS: 0=Вс -> 7)
|
const dayOfWeek = today.getDay() || 7
|
||||||
|
|
||||||
if (habit.frequency === 'daily') {
|
if (isHabitFrozenOnDate(habit, freezes, today)) return false
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (habit.frequency === 'weekly') {
|
const startDate = habit.start_date
|
||||||
// Проверяем, выбран ли сегодняшний день
|
? startOfDay(parseISO(habit.start_date))
|
||||||
if (habit.target_days && habit.target_days.includes(dayOfWeek)) {
|
: startOfDay(parseISO(habit.created_at))
|
||||||
return true
|
|
||||||
|
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
|
if (!lastLogDate) return true
|
||||||
|
|
||||||
const weekStart = startOfWeek(today, { weekStartsOn: 1 })
|
const weekStart = startOfWeek(today, { weekStartsOn: 1 })
|
||||||
const lastLog = typeof lastLogDate === 'string' ? parseISO(lastLogDate) : lastLogDate
|
const lastLog = typeof lastLogDate === "string" ? parseISO(lastLogDate) : lastLogDate
|
||||||
|
return lastLog < weekStart
|
||||||
if (lastLog < weekStart) {
|
|
||||||
return true // Не выполнялась на этой неделе
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// custom (every_n_days) - показывать если прошло N+ дней
|
if (habit.frequency === "interval" && habit.target_count > 0) {
|
||||||
if (habit.frequency === 'custom' && habit.target_count > 0) {
|
const daysSinceStart = differenceInDays(today, startDate)
|
||||||
if (!lastLogDate) return true
|
return daysSinceStart % habit.target_count === 0
|
||||||
|
}
|
||||||
const lastLog = typeof lastLogDate === 'string' ? parseISO(lastLogDate) : lastLogDate
|
|
||||||
const daysSinceLastLog = differenceInDays(today, lastLog)
|
|
||||||
|
|
||||||
|
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
|
return daysSinceLastLog >= habit.target_count
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +74,10 @@ function formatDueDate(dateStr) {
|
|||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [todayLogs, setTodayLogs] = useState({})
|
const [todayLogs, setTodayLogs] = useState({})
|
||||||
const [lastLogDates, setLastLogDates] = useState({})
|
const [lastLogDates, setLastLogDates] = useState({})
|
||||||
|
const [habitFreezes, setHabitFreezes] = useState({})
|
||||||
|
const [habitLogs, setHabitLogs] = useState({})
|
||||||
const [showCreateTask, setShowCreateTask] = useState(false)
|
const [showCreateTask, setShowCreateTask] = useState(false)
|
||||||
|
const [logHabitModal, setLogHabitModal] = useState({ open: false, habit: null })
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { user, logout } = useAuthStore()
|
const { user, logout } = useAuthStore()
|
||||||
|
|
||||||
@@ -85,6 +99,7 @@ export default function Home() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (habits.length > 0) {
|
if (habits.length > 0) {
|
||||||
loadTodayLogs()
|
loadTodayLogs()
|
||||||
|
loadHabitFreezes()
|
||||||
}
|
}
|
||||||
}, [habits])
|
}, [habits])
|
||||||
|
|
||||||
@@ -92,20 +107,18 @@ export default function Home() {
|
|||||||
const today = format(new Date(), 'yyyy-MM-dd')
|
const today = format(new Date(), 'yyyy-MM-dd')
|
||||||
const logsMap = {}
|
const logsMap = {}
|
||||||
const lastDates = {}
|
const lastDates = {}
|
||||||
|
const allLogs = {}
|
||||||
|
|
||||||
await Promise.all(habits.map(async (habit) => {
|
await Promise.all(habits.map(async (habit) => {
|
||||||
try {
|
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) {
|
if (logs.length > 0) {
|
||||||
const lastLog = logs[0]
|
const lastLog = logs[0]
|
||||||
const logDate = lastLog.date.split('T')[0]
|
const logDate = lastLog.date.split('T')[0]
|
||||||
lastDates[habit.id] = logDate
|
lastDates[habit.id] = logDate
|
||||||
|
if (logDate === today) logsMap[habit.id] = lastLog.id
|
||||||
if (logDate === today) {
|
|
||||||
logsMap[habit.id] = lastLog.id
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error loading logs for habit', habit.id, e)
|
console.error('Error loading logs for habit', habit.id, e)
|
||||||
@@ -114,14 +127,31 @@ export default function Home() {
|
|||||||
|
|
||||||
setTodayLogs(logsMap)
|
setTodayLogs(logsMap)
|
||||||
setLastLogDates(lastDates)
|
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({
|
const logMutation = useMutation({
|
||||||
mutationFn: (habitId) => habitsApi.log(habitId),
|
mutationFn: ({ habitId, date }) => habitsApi.log(habitId, date ? { date } : {}),
|
||||||
onSuccess: (data, habitId) => {
|
onSuccess: (data, { habitId, date }) => {
|
||||||
|
const logDate = date || format(new Date(), 'yyyy-MM-dd')
|
||||||
const today = 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: ['habits'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['stats'] })
|
queryClient.invalidateQueries({ queryKey: ['stats'] })
|
||||||
},
|
},
|
||||||
@@ -135,7 +165,6 @@ export default function Home() {
|
|||||||
delete newLogs[habitId]
|
delete newLogs[habitId]
|
||||||
return newLogs
|
return newLogs
|
||||||
})
|
})
|
||||||
// Перезагружаем логи для обновления lastLogDate
|
|
||||||
loadTodayLogs()
|
loadTodayLogs()
|
||||||
queryClient.invalidateQueries({ queryKey: ['habits'] })
|
queryClient.invalidateQueries({ queryKey: ['habits'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['stats'] })
|
queryClient.invalidateQueries({ queryKey: ['stats'] })
|
||||||
@@ -162,50 +191,51 @@ export default function Home() {
|
|||||||
if (todayLogs[habitId]) {
|
if (todayLogs[habitId]) {
|
||||||
deleteLogMutation.mutate({ habitId, logId: todayLogs[habitId] })
|
deleteLogMutation.mutate({ habitId, logId: todayLogs[habitId] })
|
||||||
} else {
|
} else {
|
||||||
logMutation.mutate(habitId)
|
logMutation.mutate({ habitId })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLogHabitDate = async (habitId, date) => {
|
||||||
|
await logMutation.mutateAsync({ habitId, date })
|
||||||
|
}
|
||||||
|
|
||||||
const handleToggleTask = (task) => {
|
const handleToggleTask = (task) => {
|
||||||
if (task.completed) {
|
if (task.completed) uncompleteTaskMutation.mutate(task.id)
|
||||||
uncompleteTaskMutation.mutate(task.id)
|
else completeTaskMutation.mutate(task.id)
|
||||||
} else {
|
|
||||||
completeTaskMutation.mutate(task.id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Фильтруем привычки для сегодня
|
|
||||||
const todayHabits = useMemo(() => {
|
const todayHabits = useMemo(() => {
|
||||||
return habits.filter(habit =>
|
return habits.filter(habit => shouldShowToday(habit, lastLogDates[habit.id], habitFreezes[habit.id]))
|
||||||
shouldShowToday(habit, lastLogDates[habit.id])
|
}, [habits, lastLogDates, habitFreezes])
|
||||||
)
|
|
||||||
}, [habits, lastLogDates])
|
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 completedCount = Object.keys(todayLogs).length
|
||||||
const totalToday = todayHabits.length
|
const totalToday = todayHabits.length
|
||||||
const today = format(new Date(), 'EEEE, d MMMM', { locale: ru })
|
const today = format(new Date(), 'EEEE, d MMMM', { locale: ru })
|
||||||
|
|
||||||
// Активные задачи (не выполненные)
|
|
||||||
const activeTasks = todayTasks.filter(t => !t.completed)
|
const activeTasks = todayTasks.filter(t => !t.completed)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-surface-50 gradient-mesh pb-24">
|
<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 backdrop-blur-xl border-b border-gray-100/50 sticky top-0 z-10">
|
<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="max-w-lg mx-auto px-4 py-4 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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" />
|
<Zap className="w-5 h-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<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}!
|
Привет, {user?.username}!
|
||||||
</h1>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
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="Выйти"
|
title="Выйти"
|
||||||
>
|
>
|
||||||
<LogOut size={20} />
|
<LogOut size={20} />
|
||||||
@@ -214,19 +244,13 @@ export default function Home() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="max-w-lg mx-auto px-4 py-6 space-y-6">
|
<main className="max-w-lg mx-auto px-4 py-6 space-y-6">
|
||||||
{/* Прогресс на сегодня */}
|
{/* Progress */}
|
||||||
<motion.div
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="card p-5">
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="card p-5"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="font-semibold text-gray-900">Прогресс на сегодня</h2>
|
<h2 className="font-semibold text-gray-900 dark:text-white">Прогресс на сегодня</h2>
|
||||||
<span className="text-sm font-medium text-primary-600">
|
<span className="text-sm font-medium text-primary-600 dark:text-primary-400">{completedCount} / {totalToday}</span>
|
||||||
{completedCount} / {totalToday}
|
|
||||||
</span>
|
|
||||||
</div>
|
</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
|
<motion.div
|
||||||
initial={{ width: 0 }}
|
initial={{ width: 0 }}
|
||||||
animate={{ width: totalToday > 0 ? `${(completedCount / totalToday) * 100}%` : '0%' }}
|
animate={{ width: totalToday > 0 ? `${(completedCount / totalToday) * 100}%` : '0%' }}
|
||||||
@@ -235,62 +259,55 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{completedCount === totalToday && totalToday > 0 && (
|
{completedCount === totalToday && totalToday > 0 && (
|
||||||
<motion.p
|
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-sm text-green-600 dark:text-green-400 mt-2 font-medium">
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
className="text-sm text-green-600 mt-2 font-medium"
|
|
||||||
>
|
|
||||||
🎉 Все привычки выполнены!
|
🎉 Все привычки выполнены!
|
||||||
</motion.p>
|
</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>
|
</motion.div>
|
||||||
|
|
||||||
{/* Статистика */}
|
{/* Stats */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<motion.div
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="card p-5">
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="card p-5"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
<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">
|
<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" />
|
<Flame className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-3xl font-display font-bold text-gray-900">{stats.today_completed}</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">Выполнено</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">Выполнено</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }} className="card p-5">
|
||||||
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="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">
|
<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" />
|
<TrendingUp className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-3xl font-display font-bold text-gray-900">{stats.active_habits}</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">Активных</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">Активных</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Задачи на сегодня */}
|
{/* Tasks */}
|
||||||
{(activeTasks.length > 0 || !tasksLoading) && (
|
{(activeTasks.length > 0 || !tasksLoading) && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<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
|
<button
|
||||||
onClick={() => setShowCreateTask(true)}
|
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} />
|
<Plus size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -299,24 +316,17 @@ export default function Home() {
|
|||||||
{tasksLoading ? (
|
{tasksLoading ? (
|
||||||
<div className="card p-5 animate-pulse">
|
<div className="card p-5 animate-pulse">
|
||||||
<div className="flex items-center gap-4">
|
<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="flex-1">
|
||||||
<div className="h-5 bg-gray-200 rounded-lg w-3/4 mb-2" />
|
<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 rounded-lg w-1/4" />
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : activeTasks.length === 0 ? (
|
) : activeTasks.length === 0 ? (
|
||||||
<motion.div
|
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="card p-6 text-center">
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
<p className="text-gray-500 dark:text-gray-400">Нет задач на сегодня</p>
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
<button onClick={() => setShowCreateTask(true)} className="mt-3 text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 font-medium">
|
||||||
className="card p-6 text-center"
|
|
||||||
>
|
|
||||||
<p className="text-gray-500">Нет задач на сегодня</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCreateTask(true)}
|
|
||||||
className="mt-3 text-sm text-primary-600 hover:text-primary-700 font-medium"
|
|
||||||
>
|
|
||||||
+ Добавить задачу
|
+ Добавить задачу
|
||||||
</button>
|
</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -324,13 +334,7 @@ export default function Home() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{activeTasks.map((task, index) => (
|
{activeTasks.map((task, index) => (
|
||||||
<TaskCard
|
<TaskCard key={task.id} task={task} index={index} onToggle={() => handleToggleTask(task)} isLoading={completeTaskMutation.isPending || uncompleteTaskMutation.isPending} />
|
||||||
key={task.id}
|
|
||||||
task={task}
|
|
||||||
index={index}
|
|
||||||
onToggle={() => handleToggleTask(task)}
|
|
||||||
isLoading={completeTaskMutation.isPending || uncompleteTaskMutation.isPending}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
@@ -338,35 +342,31 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Привычки на сегодня */}
|
{/* Habits */}
|
||||||
<div>
|
<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 ? (
|
{habitsLoading ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<div key={i} className="card p-5 animate-pulse">
|
<div key={i} className="card p-5 animate-pulse">
|
||||||
<div className="flex items-center gap-4">
|
<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="flex-1">
|
||||||
<div className="h-5 bg-gray-200 rounded-lg w-1/2 mb-2" />
|
<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 rounded-lg w-1/3" />
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/3" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : todayHabits.length === 0 ? (
|
) : todayHabits.length === 0 ? (
|
||||||
<motion.div
|
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="card p-10 text-center">
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
<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">
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
<Sparkles className="w-10 h-10 text-green-600 dark:text-green-400" />
|
||||||
className="card p-10 text-center"
|
|
||||||
>
|
|
||||||
<div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-green-100 to-green-200 flex items-center justify-center mx-auto mb-5">
|
|
||||||
<Sparkles className="w-10 h-10 text-green-600" />
|
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-display font-bold text-gray-900 mb-2">Свободный день!</h3>
|
<h3 className="text-xl font-display font-bold text-gray-900 dark:text-white mb-2">Свободный день!</h3>
|
||||||
<p className="text-gray-500">На сегодня нет запланированных привычек. Отдохни или добавь новую во вкладке "Привычки".</p>
|
<p className="text-gray-500 dark:text-gray-400">На сегодня нет запланированных привычек.</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -378,6 +378,7 @@ export default function Home() {
|
|||||||
index={index}
|
index={index}
|
||||||
isCompleted={!!todayLogs[habit.id]}
|
isCompleted={!!todayLogs[habit.id]}
|
||||||
onToggle={() => handleToggleComplete(habit.id)}
|
onToggle={() => handleToggleComplete(habit.id)}
|
||||||
|
onLongPress={() => setLogHabitModal({ open: true, habit })}
|
||||||
isLoading={logMutation.isPending || deleteLogMutation.isPending}
|
isLoading={logMutation.isPending || deleteLogMutation.isPending}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -388,10 +389,13 @@ export default function Home() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Navigation />
|
<Navigation />
|
||||||
|
<CreateTaskModal open={showCreateTask} onClose={() => setShowCreateTask(false)} />
|
||||||
<CreateTaskModal
|
<LogHabitModal
|
||||||
open={showCreateTask}
|
open={logHabitModal.open}
|
||||||
onClose={() => setShowCreateTask(false)}
|
onClose={() => setLogHabitModal({ open: false, habit: null })}
|
||||||
|
habit={logHabitModal.habit}
|
||||||
|
completedDates={habitLogs[logHabitModal.habit?.id] || []}
|
||||||
|
onLogDate={handleLogHabitDate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -421,21 +425,12 @@ function TaskCard({ task, index, onToggle, isLoading }) {
|
|||||||
className="card p-4 relative overflow-hidden"
|
className="card p-4 relative overflow-hidden"
|
||||||
>
|
>
|
||||||
{showConfetti && (
|
{showConfetti && (
|
||||||
<motion.div
|
<motion.div initial={{ opacity: 1 }} animate={{ opacity: 0 }} transition={{ duration: 1 }} className="absolute inset-0 pointer-events-none">
|
||||||
initial={{ opacity: 1 }}
|
|
||||||
animate={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 1 }}
|
|
||||||
className="absolute inset-0 pointer-events-none"
|
|
||||||
>
|
|
||||||
{[...Array(6)].map((_, i) => (
|
{[...Array(6)].map((_, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={i}
|
key={i}
|
||||||
initial={{ x: '50%', y: '50%', scale: 0 }}
|
initial={{ x: '50%', y: '50%', scale: 0 }}
|
||||||
animate={{
|
animate={{ x: `${Math.random() * 100}%`, y: `${Math.random() * 100}%`, scale: [0, 1, 0] }}
|
||||||
x: `${Math.random() * 100}%`,
|
|
||||||
y: `${Math.random() * 100}%`,
|
|
||||||
scale: [0, 1, 0]
|
|
||||||
}}
|
|
||||||
transition={{ duration: 0.6, delay: i * 0.05 }}
|
transition={{ duration: 0.6, delay: i * 0.05 }}
|
||||||
className="absolute w-2 h-2 rounded-full"
|
className="absolute w-2 h-2 rounded-full"
|
||||||
style={{ backgroundColor: task.color }}
|
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'
|
? 'bg-gradient-to-br from-green-400 to-green-500 shadow-lg shadow-green-400/30'
|
||||||
: 'border-2 hover:shadow-md'
|
: 'border-2 hover:shadow-md'
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{ borderColor: task.completed ? undefined : task.color + '40', backgroundColor: task.completed ? undefined : task.color + '10' }}
|
||||||
borderColor: task.completed ? undefined : task.color + '40',
|
|
||||||
backgroundColor: task.completed ? undefined : task.color + '10'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{task.completed ? (
|
{task.completed ? (
|
||||||
<motion.div
|
<motion.div initial={{ scale: 0, rotate: -180 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', stiffness: 500 }}>
|
||||||
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} />
|
<Check className="w-5 h-5 text-white" strokeWidth={3} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
@@ -474,16 +462,9 @@ function TaskCard({ task, index, onToggle, isLoading }) {
|
|||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className={clsx(
|
<h3 className={clsx("font-semibold truncate", task.completed ? "text-gray-400 line-through" : "text-gray-900 dark:text-white")}>{task.title}</h3>
|
||||||
"font-semibold truncate",
|
|
||||||
task.completed ? "text-gray-400 line-through" : "text-gray-900"
|
|
||||||
)}>{task.title}</h3>
|
|
||||||
|
|
||||||
{(dueDateLabel || isOverdue) && (
|
{(dueDateLabel || isOverdue) && (
|
||||||
<span className={clsx(
|
<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')}>
|
||||||
'inline-flex items-center gap-1 text-xs font-medium mt-1',
|
|
||||||
isOverdue ? 'text-red-600' : 'text-gray-500'
|
|
||||||
)}>
|
|
||||||
{isOverdue && <AlertTriangle size={12} />}
|
{isOverdue && <AlertTriangle size={12} />}
|
||||||
<Calendar size={12} />
|
<Calendar size={12} />
|
||||||
{dueDateLabel}
|
{dueDateLabel}
|
||||||
@@ -492,14 +473,7 @@ function TaskCard({ task, index, onToggle, isLoading }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{task.completed && (
|
{task.completed && (
|
||||||
<motion.button
|
<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="Отменить">
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
onClick={handleCheck}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="p-2 text-gray-400 hover:text-orange-500 hover:bg-orange-50 rounded-xl transition-all"
|
|
||||||
title="Отменить"
|
|
||||||
>
|
|
||||||
<Undo2 size={18} />
|
<Undo2 size={18} />
|
||||||
</motion.button>
|
</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 [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) => {
|
const handleCheck = (e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (isLoading) return
|
if (isLoading || isLongPress.current) return
|
||||||
if (!isCompleted) {
|
if (!isCompleted) { setShowConfetti(true); setTimeout(() => setShowConfetti(false), 1000) }
|
||||||
setShowConfetti(true)
|
|
||||||
setTimeout(() => setShowConfetti(false), 1000)
|
|
||||||
}
|
|
||||||
onToggle()
|
onToggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleContextMenu = (e) => { e.preventDefault(); onLongPress() }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
@@ -528,23 +510,15 @@ function HabitCard({ habit, index, isCompleted, onToggle, isLoading }) {
|
|||||||
exit={{ opacity: 0, x: -100 }}
|
exit={{ opacity: 0, x: -100 }}
|
||||||
transition={{ delay: index * 0.05 }}
|
transition={{ delay: index * 0.05 }}
|
||||||
className="card p-5 relative overflow-hidden"
|
className="card p-5 relative overflow-hidden"
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
>
|
>
|
||||||
{showConfetti && (
|
{showConfetti && (
|
||||||
<motion.div
|
<motion.div initial={{ opacity: 1 }} animate={{ opacity: 0 }} transition={{ duration: 1 }} className="absolute inset-0 pointer-events-none">
|
||||||
initial={{ opacity: 1 }}
|
|
||||||
animate={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 1 }}
|
|
||||||
className="absolute inset-0 pointer-events-none"
|
|
||||||
>
|
|
||||||
{[...Array(6)].map((_, i) => (
|
{[...Array(6)].map((_, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={i}
|
key={i}
|
||||||
initial={{ x: '50%', y: '50%', scale: 0 }}
|
initial={{ x: '50%', y: '50%', scale: 0 }}
|
||||||
animate={{
|
animate={{ x: `${Math.random() * 100}%`, y: `${Math.random() * 100}%`, scale: [0, 1, 0] }}
|
||||||
x: `${Math.random() * 100}%`,
|
|
||||||
y: `${Math.random() * 100}%`,
|
|
||||||
scale: [0, 1, 0]
|
|
||||||
}}
|
|
||||||
transition={{ duration: 0.6, delay: i * 0.05 }}
|
transition={{ duration: 0.6, delay: i * 0.05 }}
|
||||||
className="absolute w-2 h-2 rounded-full"
|
className="absolute w-2 h-2 rounded-full"
|
||||||
style={{ backgroundColor: habit.color }}
|
style={{ backgroundColor: habit.color }}
|
||||||
@@ -556,6 +530,11 @@ function HabitCard({ habit, index, isCompleted, onToggle, isLoading }) {
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<motion.button
|
<motion.button
|
||||||
onClick={handleCheck}
|
onClick={handleCheck}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
onMouseDown={handleTouchStart}
|
||||||
|
onMouseUp={handleTouchEnd}
|
||||||
|
onMouseLeave={handleTouchEnd}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
whileTap={{ scale: 0.9 }}
|
whileTap={{ scale: 0.9 }}
|
||||||
className={clsx(
|
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'
|
? 'bg-gradient-to-br from-green-400 to-green-500 shadow-lg shadow-green-400/30'
|
||||||
: 'border-2 hover:shadow-md'
|
: 'border-2 hover:shadow-md'
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{ borderColor: isCompleted ? undefined : habit.color + '40', backgroundColor: isCompleted ? undefined : habit.color + '10' }}
|
||||||
borderColor: isCompleted ? undefined : habit.color + '40',
|
|
||||||
backgroundColor: isCompleted ? undefined : habit.color + '10'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{isCompleted ? (
|
{isCompleted ? (
|
||||||
<motion.div
|
<motion.div initial={{ scale: 0, rotate: -180 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', stiffness: 500 }}>
|
||||||
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} />
|
<Check className="w-7 h-7 text-white" strokeWidth={3} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
@@ -583,27 +555,20 @@ function HabitCard({ habit, index, isCompleted, onToggle, isLoading }) {
|
|||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className={clsx(
|
<h3 className={clsx("font-semibold text-lg truncate", isCompleted ? "text-gray-400 line-through" : "text-gray-900 dark:text-white")}>{habit.name}</h3>
|
||||||
"font-semibold text-lg truncate",
|
{habit.description && <p className="text-sm text-gray-500 dark:text-gray-400 truncate">{habit.description}</p>}
|
||||||
isCompleted ? "text-gray-400 line-through" : "text-gray-900"
|
|
||||||
)}>{habit.name}</h3>
|
|
||||||
{habit.description && (
|
|
||||||
<p className="text-sm text-gray-500 truncate">{habit.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isCompleted && (
|
<div className="flex items-center gap-2">
|
||||||
<motion.button
|
<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="Отметить за другой день">
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
<Calendar size={20} />
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
</button>
|
||||||
onClick={handleCheck}
|
{isCompleted && (
|
||||||
disabled={isLoading}
|
<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="Отменить">
|
||||||
className="p-2 text-gray-400 hover:text-orange-500 hover:bg-orange-50 rounded-xl transition-all"
|
<Undo2 size={20} />
|
||||||
title="Отменить"
|
</motion.button>
|
||||||
>
|
)}
|
||||||
<Undo2 size={20} />
|
</div>
|
||||||
</motion.button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,108 +30,48 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4 gradient-mesh bg-surface-50">
|
<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
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} className="w-full max-w-md">
|
||||||
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">
|
<div className="text-center mb-8">
|
||||||
<motion.div
|
<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">
|
||||||
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" />
|
<Zap className="w-10 h-10 text-white" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<motion.h1
|
<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">
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ delay: 0.2 }}
|
|
||||||
className="text-3xl font-display font-bold text-gray-900"
|
|
||||||
>
|
|
||||||
С возвращением!
|
С возвращением!
|
||||||
</motion.h1>
|
</motion.h1>
|
||||||
<motion.p
|
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.3 }} className="text-gray-500 dark:text-gray-400 mt-2">
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ delay: 0.3 }}
|
|
||||||
className="text-gray-500 mt-2"
|
|
||||||
>
|
|
||||||
Войди, чтобы продолжить
|
Войди, чтобы продолжить
|
||||||
</motion.p>
|
</motion.p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} className="card p-8">
|
||||||
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">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
{error && (
|
{error && (
|
||||||
<motion.div
|
<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">
|
||||||
initial={{ opacity: 0, height: 0 }}
|
|
||||||
animate={{ opacity: 1, height: 'auto' }}
|
|
||||||
className="p-4 rounded-2xl bg-red-50 text-red-600 text-sm font-medium"
|
|
||||||
>
|
|
||||||
{error}
|
{error}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Email</label>
|
||||||
Email
|
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input" placeholder="your@email.com" required />
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="input"
|
|
||||||
placeholder="your@email.com"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Пароль</label>
|
||||||
Пароль
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input type={showPassword ? 'text' : 'password'} value={password} onChange={(e) => setPassword(e.target.value)} className="input pr-12" placeholder="••••••••" required />
|
||||||
type={showPassword ? 'text' : 'password'}
|
<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">
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="input pr-12"
|
|
||||||
placeholder="••••••••"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Link
|
<Link to="/forgot-password" className="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 font-medium">Забыли пароль?</Link>
|
||||||
to="/forgot-password"
|
|
||||||
className="text-sm text-primary-600 hover:text-primary-700 font-medium"
|
|
||||||
>
|
|
||||||
Забыли пароль?
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button type="submit" disabled={loading} className="btn btn-primary w-full text-lg">
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="btn btn-primary w-full text-lg"
|
|
||||||
>
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||||
@@ -144,12 +84,9 @@ export default function Login() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-8 pt-6 border-t border-gray-100 text-center">
|
<div className="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800 text-center">
|
||||||
<p className="text-gray-500">
|
<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>
|
||||||
<Link to="/register" className="text-primary-600 hover:text-primary-700 font-semibold">
|
|
||||||
Зарегистрируйся
|
|
||||||
</Link>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -31,103 +31,51 @@ export default function Register() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<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
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="w-full max-w-md">
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="w-full max-w-md"
|
|
||||||
>
|
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<motion.div
|
<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">
|
||||||
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" />
|
<Sparkles className="w-8 h-8 text-white" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Создай аккаунт</h1>
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Создай аккаунт</h1>
|
||||||
<p className="text-gray-500 mt-1">Начни отслеживать свои привычки</p>
|
<p className="text-gray-500 dark:text-gray-400 mt-1">Начни отслеживать свои привычки</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card p-6">
|
<div className="card p-6">
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<motion.div
|
<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">
|
||||||
initial={{ opacity: 0, height: 0 }}
|
|
||||||
animate={{ opacity: 1, height: 'auto' }}
|
|
||||||
className="p-3 rounded-xl bg-red-50 text-red-600 text-sm"
|
|
||||||
>
|
|
||||||
{error}
|
{error}
|
||||||
</motion.div>
|
</motion.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 type="text" value={username} onChange={(e) => setUsername(e.target.value)} className="input" placeholder="Имя" required />
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
className="input"
|
|
||||||
placeholder="Имя"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</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">Email</label>
|
||||||
Email
|
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input" placeholder="your@email.com" required />
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="input"
|
|
||||||
placeholder="your@email.com"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</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>
|
||||||
Пароль
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input type={showPassword ? 'text' : 'password'} value={password} onChange={(e) => setPassword(e.target.value)} className="input pr-12" placeholder="Минимум 8 символов" minLength={8} required />
|
||||||
type={showPassword ? 'text' : 'password'}
|
<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">
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="input pr-12"
|
|
||||||
placeholder="Минимум 8 символов"
|
|
||||||
minLength={8}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button type="submit" disabled={loading} className="btn btn-primary w-full">
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="btn btn-primary w-full"
|
|
||||||
>
|
|
||||||
{loading ? 'Создаём...' : 'Создать аккаунт'}
|
{loading ? 'Создаём...' : 'Создать аккаунт'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="text-center text-sm text-gray-500 mt-6">
|
<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>
|
||||||
<Link to="/login" className="text-primary-600 hover:text-primary-700 font-medium">
|
|
||||||
Войти
|
|
||||||
</Link>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.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 { useState, useEffect } from "react"
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
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 { Link } from "react-router-dom"
|
||||||
import { profileApi } from "../api/profile"
|
import { profileApi } from "../api/profile"
|
||||||
|
import { useTheme } from "../contexts/ThemeContext"
|
||||||
import Navigation from "../components/Navigation"
|
import Navigation from "../components/Navigation"
|
||||||
|
|
||||||
const TIMEZONES = [
|
const TIMEZONES = [
|
||||||
@@ -26,6 +27,7 @@ const TIMEZONES = [
|
|||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const { theme, toggleTheme } = useTheme()
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [username, setUsername] = useState("")
|
const [username, setUsername] = useState("")
|
||||||
const [chatId, setChatId] = useState("")
|
const [chatId, setChatId] = useState("")
|
||||||
@@ -99,39 +101,80 @@ export default function Settings() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
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 className="w-10 h-10 border-4 border-primary-500 border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
||||||
<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">
|
<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} />
|
<ArrowLeft size={20} />
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-xl font-bold">Настройки</h1>
|
<h1 className="text-xl font-bold dark:text-white">Настройки</h1>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="max-w-lg mx-auto px-4 py-6 space-y-6">
|
<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="flex items-center gap-3 mb-4">
|
||||||
<div className="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-xl bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center">
|
||||||
<User className="text-green-600" size={20} />
|
<Palette className="text-violet-600 dark:text-violet-400" size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold">Профиль</h2>
|
<h2 className="font-semibold dark:text-white">Оформление</h2>
|
||||||
<p className="text-sm text-gray-500">Основная информация</p>
|
<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>
|
</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>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -145,36 +188,36 @@ export default function Settings() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Telegram 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="flex items-center gap-3 mb-4">
|
||||||
<div className="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center">
|
<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" size={20} />
|
<MessageCircle className="text-blue-600 dark:text-blue-400" size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold">Telegram</h2>
|
<h2 className="font-semibold dark:text-white">Telegram</h2>
|
||||||
<p className="text-sm text-gray-500">Получай уведомления в Telegram</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">Получай уведомления в Telegram</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="p-3 bg-blue-50 rounded-xl">
|
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-xl">
|
||||||
<p className="text-sm text-blue-800 mb-2">
|
<p className="text-sm text-blue-800 dark:text-blue-300 mb-2">
|
||||||
1. Напиши <code className="bg-blue-100 px-1 rounded">/start</code> боту в Telegram
|
1. Напиши <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">/start</code> боту в Telegram
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={copyInstruction}
|
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 ? <Check size={16} /> : <Copy size={16} />}
|
||||||
{copied ? "Скопировано!" : "@pulse_tracking_bot"}
|
{copied ? "Скопировано!" : "@pulse_tracking_bot"}
|
||||||
</button>
|
</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 из ответа бота и вставь ниже
|
2. Скопируй Chat ID из ответа бота и вставь ниже
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
||||||
Chat ID
|
Chat ID
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -189,20 +232,20 @@ export default function Settings() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Notifications 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="flex items-center gap-3 mb-4">
|
||||||
<div className="w-10 h-10 rounded-xl bg-orange-100 flex items-center justify-center">
|
<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" size={20} />
|
<Bell className="text-orange-600 dark:text-orange-400" size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold">Уведомления</h2>
|
<h2 className="font-semibold dark:text-white">Уведомления</h2>
|
||||||
<p className="text-sm text-gray-500">Настрой ежедневные уведомления</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">Настрой ежедневные уведомления</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<label className="flex items-center justify-between p-3 bg-gray-50 rounded-xl cursor-pointer">
|
<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">Включить уведомления</span>
|
<span className="text-sm font-medium dark:text-white">Включить уведомления</span>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -210,42 +253,42 @@ export default function Settings() {
|
|||||||
onChange={(e) => setNotificationsEnabled(e.target.checked)}
|
onChange={(e) => setNotificationsEnabled(e.target.checked)}
|
||||||
className="sr-only peer"
|
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 className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full peer-checked:translate-x-5 transition-transform"></div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{notificationsEnabled && (
|
{notificationsEnabled && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-3 p-3 bg-yellow-50 rounded-xl">
|
<div className="flex items-center gap-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-xl">
|
||||||
<Sun className="text-yellow-600" size={20} />
|
<Sun className="text-yellow-600 dark:text-yellow-400" size={20} />
|
||||||
<div className="flex-1">
|
<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>
|
</label>
|
||||||
<p className="text-xs text-gray-500">Задачи и привычки на сегодня</p>
|
<p className="text-xs text-gray-500 dark:text-gray-400">Задачи и привычки на сегодня</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
value={morningTime}
|
value={morningTime}
|
||||||
onChange={(e) => setMorningTime(e.target.value)}
|
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>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 p-3 bg-indigo-50 rounded-xl">
|
<div className="flex items-center gap-3 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-xl">
|
||||||
<Moon className="text-indigo-600" size={20} />
|
<Moon className="text-indigo-600 dark:text-indigo-400" size={20} />
|
||||||
<div className="flex-1">
|
<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>
|
</label>
|
||||||
<p className="text-xs text-gray-500">Итоги дня: выполнено / осталось</p>
|
<p className="text-xs text-gray-500 dark:text-gray-400">Итоги дня: выполнено / осталось</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
value={eveningTime}
|
value={eveningTime}
|
||||||
onChange={(e) => setEveningTime(e.target.value)}
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -254,14 +297,14 @@ export default function Settings() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Timezone 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="flex items-center gap-3 mb-4">
|
||||||
<div className="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
|
<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" size={20} />
|
<Globe className="text-purple-600 dark:text-purple-400" size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold">Часовой пояс</h2>
|
<h2 className="font-semibold dark:text-white">Часовой пояс</h2>
|
||||||
<p className="text-sm text-gray-500">Для корректных напоминаний</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">Для корректных напоминаний</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -291,7 +334,7 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{mutation.isSuccess && !hasChanges && (
|
{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>
|
</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 { useQuery } from '@tanstack/react-query'
|
||||||
import { motion } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { ChevronLeft, ChevronRight, Flame, Target, TrendingUp, BarChart3 } from 'lucide-react'
|
import { ChevronDown, Flame, Trophy, CheckCircle2, TrendingUp, BarChart3, Calendar, Sparkles, Target, Zap } from 'lucide-react'
|
||||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isToday, subMonths, addMonths, parseISO, isSameMonth } from 'date-fns'
|
import { format, subDays, parseISO, startOfDay, differenceInDays, isBefore, isAfter, eachDayOfInterval, startOfMonth, getDay, addDays } from 'date-fns'
|
||||||
import { ru } from 'date-fns/locale'
|
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 { habitsApi } from '../api/habits'
|
||||||
import Navigation from '../components/Navigation'
|
import Navigation from '../components/Navigation'
|
||||||
import clsx from 'clsx'
|
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() {
|
export default function Stats() {
|
||||||
const [currentMonth, setCurrentMonth] = useState(new Date())
|
const [selectedHabitId, setSelectedHabitId] = useState(null)
|
||||||
const [allHabitLogs, setAllHabitLogs] = useState({})
|
const [allHabitLogs, setAllHabitLogs] = useState({})
|
||||||
const [allHabitStats, setAllHabitStats] = useState({})
|
const [allHabitStats, setAllHabitStats] = useState({})
|
||||||
const [selectedHabitId, setSelectedHabitId] = useState(null)
|
const [allHabitFreezes, setAllHabitFreezes] = useState({})
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||||
|
|
||||||
const { data: habits = [] } = useQuery({
|
const { data: habits = [] } = useQuery({
|
||||||
queryKey: ['habits'],
|
queryKey: ['habits'],
|
||||||
queryFn: habitsApi.list,
|
queryFn: habitsApi.list,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Загрузка логов и статистики для всех привычек
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (habits.length > 0) {
|
if (habits.length > 0) loadAllHabitsData()
|
||||||
loadAllHabitsData()
|
|
||||||
}
|
|
||||||
}, [habits])
|
}, [habits])
|
||||||
|
|
||||||
const loadAllHabitsData = async () => {
|
const loadAllHabitsData = async () => {
|
||||||
const logsMap = {}
|
const logsMap = {}
|
||||||
const statsMap = {}
|
const statsMap = {}
|
||||||
|
const freezesMap = {}
|
||||||
|
|
||||||
await Promise.all(habits.map(async (habit) => {
|
await Promise.all(habits.map(async (habit) => {
|
||||||
try {
|
try {
|
||||||
const [logs, stats] = await Promise.all([
|
const [logs, stats, freezes] = await Promise.all([
|
||||||
habitsApi.getLogs(habit.id, 90),
|
habitsApi.getLogs(habit.id, 90),
|
||||||
habitsApi.getHabitStats(habit.id),
|
habitsApi.getHabitStats(habit.id),
|
||||||
|
habitsApi.getFreezes(habit.id),
|
||||||
])
|
])
|
||||||
logsMap[habit.id] = logs
|
logsMap[habit.id] = logs
|
||||||
statsMap[habit.id] = stats
|
statsMap[habit.id] = stats
|
||||||
|
freezesMap[habit.id] = freezes
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Error loading data for habit ${habit.id}:`, e)
|
|
||||||
logsMap[habit.id] = []
|
logsMap[habit.id] = []
|
||||||
statsMap[habit.id] = null
|
statsMap[habit.id] = null
|
||||||
|
freezesMap[habit.id] = []
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
setAllHabitLogs(logsMap)
|
setAllHabitLogs(logsMap)
|
||||||
setAllHabitStats(statsMap)
|
setAllHabitStats(statsMap)
|
||||||
|
setAllHabitFreezes(freezesMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
const monthStart = startOfMonth(currentMonth)
|
// Combined stats for all or selected habit
|
||||||
const monthEnd = endOfMonth(currentMonth)
|
const computedStats = useMemo(() => {
|
||||||
const daysInMonth = eachDayOfInterval({ start: monthStart, end: monthEnd })
|
const targetHabits = selectedHabitId
|
||||||
|
? habits.filter(h => h.id === selectedHabitId)
|
||||||
|
: habits
|
||||||
|
|
||||||
const startDayOfWeek = monthStart.getDay()
|
let totalLogs = 0
|
||||||
const paddingDays = startDayOfWeek === 0 ? 6 : startDayOfWeek - 1
|
let totalExpected = 0
|
||||||
|
let currentStreak = 0
|
||||||
|
let bestStreak = 0
|
||||||
|
|
||||||
// Получить привычки, выполненные в конкретный день
|
targetHabits.forEach(habit => {
|
||||||
const getCompletedHabitsForDate = (date) => {
|
|
||||||
const dateStr = format(date, 'yyyy-MM-dd')
|
|
||||||
return habits.filter(habit => {
|
|
||||||
const logs = allHabitLogs[habit.id] || []
|
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
|
||||||
const monthlyStats = useMemo(() => {
|
|
||||||
const stats = {}
|
return { totalLogs, currentStreak, bestStreak, rate }
|
||||||
habits.forEach(habit => {
|
}, [selectedHabitId, habits, allHabitLogs, allHabitStats, allHabitFreezes])
|
||||||
const logs = allHabitLogs[habit.id] || []
|
|
||||||
const monthLogs = logs.filter(log => {
|
// Heatmap data (12 weeks = 84 days)
|
||||||
const logDate = parseISO(log.date.split('T')[0])
|
const heatmapData = useMemo(() => {
|
||||||
return isSameMonth(logDate, currentMonth)
|
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
|
}, [selectedHabitId, habits, allHabitLogs, allHabitFreezes])
|
||||||
}, [habits, allHabitLogs, currentMonth])
|
|
||||||
|
|
||||||
// Общий % выполнения за месяц
|
// Get unique months for heatmap labels
|
||||||
const overallMonthlyPercent = useMemo(() => {
|
const heatmapMonths = useMemo(() => {
|
||||||
if (habits.length === 0) return 0
|
const months = []
|
||||||
const totalPossible = habits.length * daysInMonth.length
|
let currentMonth = null
|
||||||
const totalCompleted = Object.values(monthlyStats).reduce((sum, val) => sum + val, 0)
|
heatmapData.forEach((day, index) => {
|
||||||
return Math.round((totalCompleted / totalPossible) * 100)
|
const month = format(day.date, 'MMM', { locale: ru })
|
||||||
}, [habits, monthlyStats, daysInMonth])
|
if (month !== currentMonth) {
|
||||||
|
months.push({ month, index: Math.floor(index / 7) })
|
||||||
|
currentMonth = month
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return months
|
||||||
|
}, [heatmapData])
|
||||||
|
|
||||||
const prevMonth = () => setCurrentMonth(subMonths(currentMonth, 1))
|
// Line chart data (30 days completion rate)
|
||||||
const nextMonth = () => setCurrentMonth(addMonths(currentMonth, 1))
|
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 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 (
|
return (
|
||||||
<div className="min-h-screen bg-surface-50 gradient-mesh pb-24">
|
<div className="min-h-screen bg-gray-950 pb-24 transition-colors duration-300">
|
||||||
<header className="bg-white/70 backdrop-blur-xl border-b border-gray-100/50 sticky top-0 z-10">
|
{/* Gradient Background */}
|
||||||
<div className="max-w-lg mx-auto px-4 py-4">
|
<div className="fixed inset-0 pointer-events-none">
|
||||||
<h1 className="text-xl font-display font-bold text-gray-900">Статистика</h1>
|
<div className="absolute top-0 left-1/4 w-96 h-96 bg-primary-500/10 rounded-full blur-3xl" />
|
||||||
<p className="text-sm text-gray-500">Общий прогресс по привычкам</p>
|
<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>
|
</div>
|
||||||
</header>
|
</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">
|
||||||
|
|
||||||
{/* Легенда с привычками */}
|
{/* Habit Selector Dropdown */}
|
||||||
{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
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: -10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.1 }}
|
className="relative"
|
||||||
className="card p-5"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<button
|
||||||
<button
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||||
onClick={prevMonth}
|
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"
|
||||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-all"
|
>
|
||||||
>
|
<div className="flex items-center gap-3">
|
||||||
<ChevronLeft size={20} />
|
{selectedHabit ? (
|
||||||
</button>
|
<>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 capitalize">
|
<div
|
||||||
{format(currentMonth, 'LLLL yyyy', { locale: ru })}
|
className="w-10 h-10 rounded-xl flex items-center justify-center text-xl"
|
||||||
</h3>
|
style={{ backgroundColor: selectedHabit.color + '20' }}
|
||||||
<button
|
>
|
||||||
onClick={nextMonth}
|
{selectedHabit.icon}
|
||||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-all"
|
</div>
|
||||||
>
|
<div className="text-left">
|
||||||
<ChevronRight size={20} />
|
<span className="font-semibold text-white block">{selectedHabit.name}</span>
|
||||||
</button>
|
<span className="text-xs text-gray-500">Выбранная привычка</span>
|
||||||
</div>
|
</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>
|
||||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
{dropdownOpen && (
|
||||||
{['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map((day) => (
|
<motion.div
|
||||||
<div key={day} className="text-center text-xs font-medium text-gray-400 py-2">
|
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||||
{day}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
</div>
|
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||||
))}
|
transition={{ duration: 0.2 }}
|
||||||
</div>
|
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
|
||||||
<div className="grid grid-cols-7 gap-1">
|
onClick={() => { setSelectedHabitId(null); setDropdownOpen(false) }}
|
||||||
{[...Array(paddingDays)].map((_, i) => (
|
|
||||||
<div key={'pad' + i} className="aspect-square" />
|
|
||||||
))}
|
|
||||||
|
|
||||||
{daysInMonth.map((day) => {
|
|
||||||
const completedHabits = getCompletedHabitsForDate(day)
|
|
||||||
const today = isToday(day)
|
|
||||||
const future = day > new Date()
|
|
||||||
const completedCount = completedHabits.length
|
|
||||||
const totalCount = habits.length
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={day.toISOString()}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'aspect-square flex flex-col items-center justify-center rounded-lg relative p-1',
|
"w-full p-4 flex items-center gap-3 hover:bg-gray-800 transition-colors border-b border-gray-800/50",
|
||||||
today && 'ring-2 ring-primary-500 bg-primary-50',
|
!selectedHabitId && "bg-primary-500/10"
|
||||||
future && 'opacity-40',
|
|
||||||
!today && completedCount > 0 && 'bg-gray-50'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Число дня */}
|
<Target className="text-primary-400" size={20} />
|
||||||
<span className={clsx(
|
<span className="font-medium text-white">Все привычки</span>
|
||||||
'text-sm font-medium',
|
{!selectedHabitId && <div className="ml-auto w-2 h-2 rounded-full bg-primary-400" />}
|
||||||
today ? 'text-primary-600' : completedCount > 0 ? 'text-gray-900' : 'text-gray-400'
|
</button>
|
||||||
)}>
|
{habits.map(habit => (
|
||||||
{format(day, 'd')}
|
<button
|
||||||
</span>
|
key={habit.id}
|
||||||
|
onClick={() => { setSelectedHabitId(habit.id); setDropdownOpen(false) }}
|
||||||
{/* Цветные точки выполненных привычек */}
|
className={clsx(
|
||||||
{completedCount > 0 && (
|
"w-full p-4 flex items-center gap-3 hover:bg-gray-800 transition-colors",
|
||||||
<div className="flex flex-wrap gap-0.5 justify-center mt-0.5">
|
selectedHabitId === habit.id && "bg-primary-500/10"
|
||||||
{completedHabits.slice(0, 4).map((habit) => (
|
)}
|
||||||
<motion.div
|
>
|
||||||
key={habit.id}
|
<span className="text-xl">{habit.icon}</span>
|
||||||
initial={{ scale: 0 }}
|
<span className="font-medium text-white">{habit.name}</span>
|
||||||
animate={{ scale: 1 }}
|
{selectedHabitId === habit.id && <div className="ml-auto w-2 h-2 rounded-full bg-primary-400" />}
|
||||||
className="w-1.5 h-1.5 rounded-full"
|
</button>
|
||||||
style={{ backgroundColor: habit.color }}
|
))}
|
||||||
/>
|
</motion.div>
|
||||||
))}
|
)}
|
||||||
{completedHabits.length > 4 && (
|
</AnimatePresence>
|
||||||
<span className="text-[8px] text-gray-400">+{completedHabits.length - 4}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* X/Y в углу */}
|
|
||||||
{totalCount > 0 && !future && (
|
|
||||||
<span className={clsx(
|
|
||||||
'absolute bottom-0.5 right-0.5 text-[8px] font-medium',
|
|
||||||
completedCount === totalCount ? 'text-green-500' :
|
|
||||||
completedCount > 0 ? 'text-gray-400' : 'text-gray-300'
|
|
||||||
)}>
|
|
||||||
{completedCount}/{totalCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Monthly Summary - карточки для каждой привычки */}
|
{/* Stats Cards */}
|
||||||
{habits.length > 0 && (
|
<section>
|
||||||
<motion.div
|
<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 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.15 }}
|
transition={{ delay: 0.4 }}
|
||||||
className="card p-5"
|
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">
|
<SectionHeader
|
||||||
<BarChart3 className="w-5 h-5 text-primary-500" />
|
icon={BarChart3}
|
||||||
Итоги месяца
|
title="По привычкам"
|
||||||
</h3>
|
subtitle="Рейтинг за 30 дней"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{habits.map((habit) => {
|
{barChartData.map((habit, index) => (
|
||||||
const completed = monthlyStats[habit.id] || 0
|
<motion.div
|
||||||
const total = daysInMonth.length
|
key={habit.name}
|
||||||
const percent = Math.round((completed / total) * 100)
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
return (
|
transition={{ delay: 0.1 * index }}
|
||||||
<div
|
className="relative"
|
||||||
key={habit.id}
|
>
|
||||||
className="p-3 rounded-xl bg-gray-50 hover:bg-gray-100 transition-all cursor-pointer"
|
<div className="flex items-center gap-3 mb-2">
|
||||||
onClick={() => setSelectedHabitId(selectedHabitId === habit.id ? null : habit.id)}
|
<span className="text-lg">{habit.icon}</span>
|
||||||
>
|
<span className="text-sm font-medium text-white flex-1 truncate">{habit.name}</span>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<span className="text-sm font-bold text-primary-400">{habit.rate}%</span>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className="w-3 h-3 rounded-full"
|
|
||||||
style={{ backgroundColor: habit.color }}
|
|
||||||
/>
|
|
||||||
<span className="text-lg">{habit.icon}</span>
|
|
||||||
<span className="font-medium text-gray-900">{habit.name}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-bold text-gray-700">{completed}/{total}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress bar */}
|
|
||||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
|
||||||
<motion.div
|
|
||||||
initial={{ width: 0 }}
|
|
||||||
animate={{ width: `${percent}%` }}
|
|
||||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
|
||||||
className="h-full rounded-full"
|
|
||||||
style={{ backgroundColor: habit.color }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-right mt-1">
|
|
||||||
<span className="text-xs text-gray-500">{percent}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
<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>
|
</div>
|
||||||
</motion.div>
|
</motion.section>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Детальная статистика для выбранной привычки */}
|
|
||||||
{selectedHabit && selectedStats && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="card p-5"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<div
|
|
||||||
className="w-10 h-10 rounded-xl flex items-center justify-center text-xl"
|
|
||||||
style={{ backgroundColor: selectedHabit.color + '20' }}
|
|
||||||
>
|
|
||||||
{selectedHabit.icon}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">{selectedHabit.name}</h3>
|
|
||||||
<p className="text-sm text-gray-500">Детальная статистика</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Streak карточки */}
|
|
||||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
|
||||||
<div className="text-center p-3 bg-gradient-to-br from-accent-50 to-accent-100 rounded-xl">
|
|
||||||
<Flame className="w-5 h-5 text-accent-500 mx-auto mb-1" />
|
|
||||||
<p className="text-xl font-bold text-gray-900">{selectedStats.current_streak}</p>
|
|
||||||
<p className="text-xs text-gray-500">Текущий</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-gradient-to-br from-primary-50 to-primary-100 rounded-xl">
|
|
||||||
<TrendingUp className="w-5 h-5 text-primary-500 mx-auto mb-1" />
|
|
||||||
<p className="text-xl font-bold text-gray-900">{selectedStats.longest_streak}</p>
|
|
||||||
<p className="text-xs text-gray-500">Лучший</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-gradient-to-br from-green-50 to-green-100 rounded-xl">
|
|
||||||
<Target className="w-5 h-5 text-green-500 mx-auto mb-1" />
|
|
||||||
<p className="text-xl font-bold text-gray-900">{Math.round(selectedStats.completion_pct || 0)}%</p>
|
|
||||||
<p className="text-xs text-gray-500">Выполнение</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Детальные данные */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between items-center py-2 border-b border-gray-100">
|
|
||||||
<span className="text-gray-600">Всего выполнений</span>
|
|
||||||
<span className="font-semibold text-gray-900">{selectedStats.total_logs || 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center py-2 border-b border-gray-100">
|
|
||||||
<span className="text-gray-600">За эту неделю</span>
|
|
||||||
<span className="font-semibold text-gray-900">{selectedStats.this_week || 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center py-2 border-b border-gray-100">
|
|
||||||
<span className="text-gray-600">За этот месяц</span>
|
|
||||||
<span className="font-semibold text-gray-900">{selectedStats.this_month || 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center py-2">
|
|
||||||
<span className="text-gray-600">Дата создания</span>
|
|
||||||
<span className="font-semibold text-gray-900">
|
|
||||||
{format(parseISO(selectedHabit.created_at), 'd MMM yyyy', { locale: ru })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{habits.length === 0 && (
|
{habits.length === 0 && (
|
||||||
<div className="card p-10 text-center">
|
<motion.div
|
||||||
<p className="text-gray-500">Создайте привычки, чтобы видеть статистику</p>
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
</div>
|
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>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
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 { format, parseISO, isToday, isTomorrow, isPast } from 'date-fns'
|
||||||
import { ru } from 'date-fns/locale'
|
import { ru } from 'date-fns/locale'
|
||||||
import { tasksApi } from '../api/tasks'
|
import { tasksApi } from '../api/tasks'
|
||||||
@@ -12,9 +12,16 @@ import clsx from 'clsx'
|
|||||||
|
|
||||||
const PRIORITY_LABELS = {
|
const PRIORITY_LABELS = {
|
||||||
0: null,
|
0: null,
|
||||||
1: { label: 'Низкий', class: 'bg-blue-100 text-blue-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' },
|
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' },
|
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) {
|
function formatDueDate(dateStr) {
|
||||||
@@ -28,7 +35,7 @@ function formatDueDate(dateStr) {
|
|||||||
export default function Tasks() {
|
export default function Tasks() {
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
const [editingTask, setEditingTask] = useState(null)
|
const [editingTask, setEditingTask] = useState(null)
|
||||||
const [filter, setFilter] = useState('active') // all, active, completed
|
const [filter, setFilter] = useState('active')
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const { data: tasks = [], isLoading } = useQuery({
|
const { data: tasks = [], isLoading } = useQuery({
|
||||||
@@ -56,31 +63,21 @@ export default function Tasks() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handleToggle = (task) => {
|
const handleToggle = (task) => {
|
||||||
if (task.completed) {
|
if (task.completed) uncompleteMutation.mutate(task.id)
|
||||||
uncompleteMutation.mutate(task.id)
|
else completeMutation.mutate(task.id)
|
||||||
} else {
|
|
||||||
completeMutation.mutate(task.id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeTasks = tasks.filter(t => !t.completed)
|
|
||||||
const completedTasks = tasks.filter(t => t.completed)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-surface-50 gradient-mesh pb-24">
|
<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 backdrop-blur-xl border-b border-gray-100/50 sticky top-0 z-10">
|
<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="max-w-lg mx-auto px-4 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-display font-bold text-gray-900">Задачи</h1>
|
<h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">Задачи</h1>
|
||||||
<button
|
<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">
|
||||||
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} />
|
<Plus size={22} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Фильтры */}
|
|
||||||
<div className="flex gap-2 mt-4">
|
<div className="flex gap-2 mt-4">
|
||||||
{[
|
{[
|
||||||
{ key: 'active', label: 'Активные' },
|
{ key: 'active', label: 'Активные' },
|
||||||
@@ -94,7 +91,7 @@ export default function Tasks() {
|
|||||||
'px-4 py-2 rounded-xl text-sm font-medium transition-all',
|
'px-4 py-2 rounded-xl text-sm font-medium transition-all',
|
||||||
filter === key
|
filter === key
|
||||||
? 'bg-primary-500 text-white shadow-lg shadow-primary-500/30'
|
? '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}
|
{label}
|
||||||
@@ -110,35 +107,28 @@ export default function Tasks() {
|
|||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<div key={i} className="card p-5 animate-pulse">
|
<div key={i} className="card p-5 animate-pulse">
|
||||||
<div className="flex items-center gap-4">
|
<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="flex-1">
|
||||||
<div className="h-5 bg-gray-200 rounded-lg w-3/4 mb-2" />
|
<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 rounded-lg w-1/4" />
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : tasks.length === 0 ? (
|
) : tasks.length === 0 ? (
|
||||||
<motion.div
|
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="card p-10 text-center">
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
<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">
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
<Check className="w-10 h-10 text-primary-600 dark:text-primary-400" />
|
||||||
className="card p-10 text-center"
|
|
||||||
>
|
|
||||||
<div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-primary-100 to-primary-200 flex items-center justify-center mx-auto mb-5">
|
|
||||||
<Check className="w-10 h-10 text-primary-600" />
|
|
||||||
</div>
|
</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' ? 'Нет выполненных задач' : 'Нет задач'}
|
{filter === 'active' ? 'Нет активных задач' : filter === 'completed' ? 'Нет выполненных задач' : 'Нет задач'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-500 mb-6">
|
<p className="text-gray-500 dark:text-gray-400 mb-6">
|
||||||
{filter === 'active' ? 'Добавь новую задачу или выбери другой фильтр' : 'Выполняй задачи и они появятся здесь'}
|
{filter === 'active' ? 'Добавь новую задачу или выбери другой фильтр' : 'Выполняй задачи и они появятся здесь'}
|
||||||
</p>
|
</p>
|
||||||
{filter === 'active' && (
|
{filter === 'active' && (
|
||||||
<button
|
<button onClick={() => setShowCreate(true)} className="btn btn-primary">
|
||||||
onClick={() => setShowCreate(true)}
|
|
||||||
className="btn btn-primary"
|
|
||||||
>
|
|
||||||
<Plus size={18} />
|
<Plus size={18} />
|
||||||
Добавить задачу
|
Добавить задачу
|
||||||
</button>
|
</button>
|
||||||
@@ -148,14 +138,7 @@ export default function Tasks() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{tasks.map((task, index) => (
|
{tasks.map((task, index) => (
|
||||||
<TaskCard
|
<TaskCard key={task.id} task={task} index={index} onToggle={() => handleToggle(task)} onEdit={() => setEditingTask(task)} isLoading={completeMutation.isPending || uncompleteMutation.isPending} />
|
||||||
key={task.id}
|
|
||||||
task={task}
|
|
||||||
index={index}
|
|
||||||
onToggle={() => handleToggle(task)}
|
|
||||||
onEdit={() => setEditingTask(task)}
|
|
||||||
isLoading={completeMutation.isPending || uncompleteMutation.isPending}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,17 +146,8 @@ export default function Tasks() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Navigation />
|
<Navigation />
|
||||||
|
<CreateTaskModal open={showCreate} onClose={() => setShowCreate(false)} />
|
||||||
<CreateTaskModal
|
<EditTaskModal open={!!editingTask} onClose={() => setEditingTask(null)} task={editingTask} />
|
||||||
open={showCreate}
|
|
||||||
onClose={() => setShowCreate(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EditTaskModal
|
|
||||||
open={!!editingTask}
|
|
||||||
onClose={() => setEditingTask(null)}
|
|
||||||
task={editingTask}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -187,10 +161,7 @@ function TaskCard({ task, index, onToggle, onEdit, isLoading }) {
|
|||||||
const handleCheck = (e) => {
|
const handleCheck = (e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (isLoading) return
|
if (isLoading) return
|
||||||
if (!task.completed) {
|
if (!task.completed) { setShowConfetti(true); setTimeout(() => setShowConfetti(false), 1000) }
|
||||||
setShowConfetti(true)
|
|
||||||
setTimeout(() => setShowConfetti(false), 1000)
|
|
||||||
}
|
|
||||||
onToggle()
|
onToggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,25 +174,9 @@ function TaskCard({ task, index, onToggle, onEdit, isLoading }) {
|
|||||||
className="card p-4 relative overflow-hidden"
|
className="card p-4 relative overflow-hidden"
|
||||||
>
|
>
|
||||||
{showConfetti && (
|
{showConfetti && (
|
||||||
<motion.div
|
<motion.div initial={{ opacity: 1 }} animate={{ opacity: 0 }} transition={{ duration: 1 }} className="absolute inset-0 pointer-events-none">
|
||||||
initial={{ opacity: 1 }}
|
|
||||||
animate={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 1 }}
|
|
||||||
className="absolute inset-0 pointer-events-none"
|
|
||||||
>
|
|
||||||
{[...Array(6)].map((_, i) => (
|
{[...Array(6)].map((_, i) => (
|
||||||
<motion.div
|
<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 }} />
|
||||||
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>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
@@ -233,21 +188,12 @@ function TaskCard({ task, index, onToggle, onEdit, isLoading }) {
|
|||||||
whileTap={{ scale: 0.9 }}
|
whileTap={{ scale: 0.9 }}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 flex-shrink-0 mt-0.5',
|
'w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 flex-shrink-0 mt-0.5',
|
||||||
task.completed
|
task.completed ? 'bg-gradient-to-br from-green-400 to-green-500 shadow-lg shadow-green-400/30' : 'border-2 hover:shadow-md'
|
||||||
? 'bg-gradient-to-br from-green-400 to-green-500 shadow-lg shadow-green-400/30'
|
|
||||||
: 'border-2 hover:shadow-md'
|
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{ borderColor: task.completed ? undefined : task.color + '40', backgroundColor: task.completed ? undefined : task.color + '10' }}
|
||||||
borderColor: task.completed ? undefined : task.color + '40',
|
|
||||||
backgroundColor: task.completed ? undefined : task.color + '10'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{task.completed ? (
|
{task.completed ? (
|
||||||
<motion.div
|
<motion.div initial={{ scale: 0, rotate: -180 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', stiffness: 500 }}>
|
||||||
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} />
|
<Check className="w-5 h-5 text-white" strokeWidth={3} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
@@ -256,57 +202,40 @@ function TaskCard({ task, index, onToggle, onEdit, isLoading }) {
|
|||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0" onClick={onEdit}>
|
<div className="flex-1 min-w-0" onClick={onEdit}>
|
||||||
<h3 className={clsx(
|
<div className="flex items-center gap-2">
|
||||||
"font-semibold truncate cursor-pointer hover:text-primary-600",
|
<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.completed ? "text-gray-400 line-through" : "text-gray-900"
|
{task.is_recurring && <span className="text-sm" title={RECURRENCE_LABELS[task.recurrence_type] || 'Повторяется'}>🔄</span>}
|
||||||
)}>{task.title}</h3>
|
</div>
|
||||||
|
|
||||||
{task.description && (
|
{task.description && <p className="text-sm text-gray-500 dark:text-gray-400 truncate mt-0.5">{task.description}</p>}
|
||||||
<p className="text-sm text-gray-500 truncate mt-0.5">{task.description}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||||
{dueDateLabel && (
|
{dueDateLabel && (
|
||||||
<span className={clsx(
|
<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')}>
|
||||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium',
|
|
||||||
isOverdue
|
|
||||||
? 'bg-red-100 text-red-700'
|
|
||||||
: 'bg-gray-100 text-gray-600'
|
|
||||||
)}>
|
|
||||||
{isOverdue && <AlertTriangle size={12} />}
|
{isOverdue && <AlertTriangle size={12} />}
|
||||||
<Calendar size={12} />
|
<Calendar size={12} />
|
||||||
{dueDateLabel}
|
{dueDateLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{priorityInfo && (
|
{priorityInfo && <span className={clsx('px-2 py-0.5 rounded-md text-xs font-medium', priorityInfo.class)}>{priorityInfo.label}</span>}
|
||||||
<span className={clsx(
|
|
||||||
'px-2 py-0.5 rounded-md text-xs font-medium',
|
{task.is_recurring && task.recurrence_type && (
|
||||||
priorityInfo.class
|
<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} />
|
||||||
{priorityInfo.label}
|
{RECURRENCE_LABELS[task.recurrence_type]}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<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">
|
||||||
onClick={onEdit}
|
|
||||||
className="p-2 text-gray-400 hover:text-primary-500 hover:bg-primary-50 rounded-xl transition-all"
|
|
||||||
>
|
|
||||||
<Edit2 size={16} />
|
<Edit2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{task.completed && (
|
{task.completed && (
|
||||||
<motion.button
|
<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="Отменить">
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
onClick={handleCheck}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="p-2 text-gray-400 hover:text-orange-500 hover:bg-orange-50 rounded-xl transition-all"
|
|
||||||
title="Отменить"
|
|
||||||
>
|
|
||||||
<Undo2 size={16} />
|
<Undo2 size={16} />
|
||||||
</motion.button>
|
</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',
|
'./index.html',
|
||||||
'./src/**/*.{js,ts,jsx,tsx}',
|
'./src/**/*.{js,ts,jsx,tsx}',
|
||||||
],
|
],
|
||||||
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
|||||||
Reference in New Issue
Block a user