From 199887e552d02410e75d5160cb609d10586cf5cd Mon Sep 17 00:00:00 2001 From: Cosmo Date: Fri, 6 Feb 2026 11:19:55 +0000 Subject: [PATCH] Initial commit: Pulse web app --- .gitignore | 6 + Dockerfile | 12 + docker-compose.yml | 12 + index.html | 19 + nginx.conf | 22 + package.json | 32 ++ postcss.config.js | 6 + public/favicon.svg | 4 + src/App.jsx | 120 ++++++ src/api/client.js | 52 +++ src/api/habits.js | 16 + src/api/tasks.js | 46 +++ src/components/CreateHabitModal.jsx | 326 +++++++++++++++ src/components/CreateTaskModal.jsx | 327 +++++++++++++++ src/components/EditHabitModal.jsx | 388 ++++++++++++++++++ src/components/EditTaskModal.jsx | 390 ++++++++++++++++++ src/components/Navigation.jsx | 38 ++ src/index.css | 85 ++++ src/main.jsx | 25 ++ src/pages/ForgotPassword.jsx | 138 +++++++ src/pages/Habits.jsx | 274 +++++++++++++ src/pages/Home.jsx | 610 ++++++++++++++++++++++++++++ src/pages/Login.jsx | 159 ++++++++ src/pages/Register.jsx | 136 +++++++ src/pages/ResetPassword.jsx | 140 +++++++ src/pages/Stats.jsx | 378 +++++++++++++++++ src/pages/Tasks.jsx | 317 +++++++++++++++ src/pages/VerifyEmail.jsx | 103 +++++ src/store/auth.js | 47 +++ tailwind.config.js | 76 ++++ vite.config.js | 10 + 31 files changed, 4314 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 index.html create mode 100644 nginx.conf create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 public/favicon.svg create mode 100644 src/App.jsx create mode 100644 src/api/client.js create mode 100644 src/api/habits.js create mode 100644 src/api/tasks.js create mode 100644 src/components/CreateHabitModal.jsx create mode 100644 src/components/CreateTaskModal.jsx create mode 100644 src/components/EditHabitModal.jsx create mode 100644 src/components/EditTaskModal.jsx create mode 100644 src/components/Navigation.jsx create mode 100644 src/index.css create mode 100644 src/main.jsx create mode 100644 src/pages/ForgotPassword.jsx create mode 100644 src/pages/Habits.jsx create mode 100644 src/pages/Home.jsx create mode 100644 src/pages/Login.jsx create mode 100644 src/pages/Register.jsx create mode 100644 src/pages/ResetPassword.jsx create mode 100644 src/pages/Stats.jsx create mode 100644 src/pages/Tasks.jsx create mode 100644 src/pages/VerifyEmail.jsx create mode 100644 src/store/auth.js create mode 100644 tailwind.config.js create mode 100644 vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94362eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env +.env.local +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f973c69 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..28a90a8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +networks: + proxy: + external: true + name: services_proxy + +services: + web: + build: . + container_name: pulse-web + restart: always + networks: + - proxy diff --git a/index.html b/index.html new file mode 100644 index 0000000..e1d68e1 --- /dev/null +++ b/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + + + + Pulse + + +
+ + + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..7ebe735 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,22 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Disable caching for favicon + location = /favicon.svg { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + } + + location / { + try_files $uri $uri/ /index.html; + } + + location /assets { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b790e67 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "habits-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.22.0", + "@tanstack/react-query": "^5.17.0", + "axios": "^1.6.5", + "zustand": "^4.5.0", + "date-fns": "^3.3.1", + "lucide-react": "^0.312.0", + "clsx": "^2.1.0", + "framer-motion": "^11.0.3" + }, + "devDependencies": { + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.17", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.1", + "vite": "^5.0.12" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..8df1a5b --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..a35c5c9 --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,120 @@ +import { useEffect } from "react" +import { Routes, Route, Navigate } from "react-router-dom" +import { useAuthStore } from "./store/auth" +import Login from "./pages/Login" +import Register from "./pages/Register" +import Home from "./pages/Home" +import Habits from "./pages/Habits" +import Tasks from "./pages/Tasks" +import VerifyEmail from "./pages/VerifyEmail" +import ResetPassword from "./pages/ResetPassword" +import ForgotPassword from "./pages/ForgotPassword" +import Stats from "./pages/Stats" + +function ProtectedRoute({ children }) { + const { isAuthenticated, isLoading } = useAuthStore() + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (!isAuthenticated) { + return + } + + return children +} + +function PublicRoute({ children }) { + const { isAuthenticated, isLoading } = useAuthStore() + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (isAuthenticated) { + return + } + + return children +} + +export default function App() { + const initialize = useAuthStore((s) => s.initialize) + + useEffect(() => { + initialize() + }, [initialize]) + + return ( + + + + + } + /> + + + + } + /> + + + + } + /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + ) +} diff --git a/src/api/client.js b/src/api/client.js new file mode 100644 index 0000000..52932d9 --- /dev/null +++ b/src/api/client.js @@ -0,0 +1,52 @@ +import axios from 'axios' + +const API_URL = import.meta.env.VITE_API_URL || 'https://api.digital-home.site' + +export const api = axios.create({ + baseURL: API_URL, + headers: { + 'Content-Type': 'application/json', + }, +}) + +api.interceptors.request.use((config) => { + const token = localStorage.getItem('access_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +api.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config + + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true + + const refreshToken = localStorage.getItem('refresh_token') + if (refreshToken) { + try { + const { data } = await axios.post(`${API_URL}/auth/refresh`, { + refresh_token: refreshToken, + }) + + localStorage.setItem('access_token', data.access_token) + localStorage.setItem('refresh_token', data.refresh_token) + + originalRequest.headers.Authorization = `Bearer ${data.access_token}` + return api(originalRequest) + } catch (refreshError) { + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + window.location.href = '/login' + } + } + } + + return Promise.reject(error) + } +) + +export default api diff --git a/src/api/habits.js b/src/api/habits.js new file mode 100644 index 0000000..2c5e0ed --- /dev/null +++ b/src/api/habits.js @@ -0,0 +1,16 @@ +import api from './client' + +export const habitsApi = { + list: () => api.get('/habits').then(r => r.data), + get: (id) => api.get(`/habits/${id}`).then(r => r.data), + create: (data) => api.post('/habits', data).then(r => r.data), + update: (id, data) => api.put(`/habits/${id}`, data).then(r => r.data), + delete: (id) => api.delete(`/habits/${id}`), + + log: (id, data = {}) => api.post(`/habits/${id}/log`, data).then(r => r.data), + getLogs: (id, days = 30) => api.get(`/habits/${id}/logs?days=${days}`).then(r => r.data), + deleteLog: (habitId, logId) => api.delete(`/habits/${habitId}/logs/${logId}`), + + getStats: () => api.get('/habits/stats').then(r => r.data), + getHabitStats: (id) => api.get(`/habits/${id}/stats`).then(r => r.data), +} diff --git a/src/api/tasks.js b/src/api/tasks.js new file mode 100644 index 0000000..e4db7cf --- /dev/null +++ b/src/api/tasks.js @@ -0,0 +1,46 @@ +import client from './client' + +export const tasksApi = { + list: async (completed = null) => { + let url = 'tasks' + if (completed !== null) { + url += `?completed=${completed}` + } + const res = await client.get(url) + return res.data + }, + + today: async () => { + const res = await client.get('tasks/today') + return res.data + }, + + get: async (id) => { + const res = await client.get(`tasks/${id}`) + return res.data + }, + + create: async (data) => { + const res = await client.post('tasks', data) + return res.data + }, + + update: async (id, data) => { + const res = await client.put(`tasks/${id}`, data) + return res.data + }, + + delete: async (id) => { + await client.delete(`tasks/${id}`) + }, + + complete: async (id) => { + const res = await client.post(`tasks/${id}/complete`) + return res.data + }, + + uncomplete: async (id) => { + const res = await client.post(`tasks/${id}/uncomplete`) + return res.data + }, +} diff --git a/src/components/CreateHabitModal.jsx b/src/components/CreateHabitModal.jsx new file mode 100644 index 0000000..423275a --- /dev/null +++ b/src/components/CreateHabitModal.jsx @@ -0,0 +1,326 @@ +import { useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { X, ChevronDown, ChevronUp } from 'lucide-react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { habitsApi } from '../api/habits' +import clsx from 'clsx' + +const COLORS = [ + '#6366f1', '#8b5cf6', '#d946ef', '#ec4899', '#f43f5e', + '#f97316', '#eab308', '#22c55e', '#14b8a6', '#0ea5e9', +] + +const ICON_CATEGORIES = [ + { name: 'Спорт', icons: ['💪', '🏃', '🚴', '🏊', '🧘', '⚽', '🏀', '🎾'] }, + { name: 'Здоровье', icons: ['💊', '💉', '🩺', '🧠', '😴', '💤', '🦷', '👁️'] }, + { name: 'Продуктивность', icons: ['📚', '📖', '✏️', '💻', '🎯', '📝', '📅', '⏰'] }, + { name: 'Дом', icons: ['🏠', '🧹', '🧺', '🍳', '🛒', '🔧', '🪴'] }, + { name: 'Финансы', icons: ['💰', '💳', '📊', '💵', '🏦'] }, + { name: 'Социальное', icons: ['👥', '💬', '📞', '👨‍👩‍👧‍👦', '❤️'] }, + { name: 'Хобби', icons: ['🎨', '🎵', '🎸', '🎮', '📷', '✈️', '🚗'] }, + { name: 'Еда/вода', icons: ['🥗', '🍎', '🥤', '☕', '🍽️', '💧'] }, + { name: 'Разное', icons: ['⭐', '🎉', '✨', '🔥', '🌟', '💎', '🎁'] }, +] + +const DAYS = [ + { id: 1, short: 'Пн', full: 'Понедельник' }, + { id: 2, short: 'Вт', full: 'Вторник' }, + { id: 3, short: 'Ср', full: 'Среда' }, + { id: 4, short: 'Чт', full: 'Четверг' }, + { id: 5, short: 'Пт', full: 'Пятница' }, + { id: 6, short: 'Сб', full: 'Суббота' }, + { id: 7, short: 'Вс', full: 'Воскресенье' }, +] + +export default function CreateHabitModal({ open, onClose }) { + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [color, setColor] = useState(COLORS[0]) + const [icon, setIcon] = useState('✨') + const [frequency, setFrequency] = useState('daily') + const [targetDays, setTargetDays] = useState([1, 2, 3, 4, 5, 6, 7]) + const [error, setError] = useState('') + const [showAllIcons, setShowAllIcons] = useState(false) + + const queryClient = useQueryClient() + + const mutation = useMutation({ + mutationFn: (data) => habitsApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['habits'] }) + queryClient.invalidateQueries({ queryKey: ['stats'] }) + handleClose() + }, + onError: (err) => { + setError(err.response?.data?.error || 'Ошибка создания') + }, + }) + + const handleClose = () => { + setName('') + setDescription('') + setColor(COLORS[0]) + setIcon('✨') + setFrequency('daily') + setTargetDays([1, 2, 3, 4, 5, 6, 7]) + setError('') + setShowAllIcons(false) + onClose() + } + + const handleSubmit = (e) => { + e.preventDefault() + if (!name.trim()) { + setError('Введи название привычки') + return + } + if (frequency === 'weekly' && targetDays.length === 0) { + setError('Выбери хотя бы один день недели') + return + } + + const data = { name, description, color, icon, frequency } + if (frequency === 'weekly') { + data.target_days = targetDays + } + + mutation.mutate(data) + } + + const toggleDay = (dayId) => { + setTargetDays(prev => + prev.includes(dayId) + ? prev.filter(d => d !== dayId) + : [...prev, dayId].sort((a, b) => a - b) + ) + } + + // Популярные иконки для быстрого выбора + const popularIcons = ['✨', '💪', '📚', '🏃', '💧', '🧘', '💤', '🎯', '✏️', '🍎'] + + return ( + + {open && ( + <> + + +
+
+

Новая привычка

+ +
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setName(e.target.value)} + className="input" + placeholder="Например: Пить воду" + autoFocus + /> +
+ +
+ + setDescription(e.target.value)} + className="input" + placeholder="8 стаканов в день" + /> +
+ +
+ +
+ + +
+
+ + {frequency === 'weekly' && ( + + +
+ {DAYS.map((day) => ( + + ))} +
+
+ )} + +
+ +
+ {popularIcons.map((ic) => ( + + ))} +
+ + + + + {showAllIcons && ( + + {ICON_CATEGORIES.map((category) => ( +
+

{category.name}

+
+ {category.icons.map((ic) => ( + + ))} +
+
+ ))} +
+ )} +
+
+ +
+ +
+ {COLORS.map((c) => ( +
+
+ +
+ +
+
+
+
+ + )} +
+ ) +} diff --git a/src/components/CreateTaskModal.jsx b/src/components/CreateTaskModal.jsx new file mode 100644 index 0000000..5d4a622 --- /dev/null +++ b/src/components/CreateTaskModal.jsx @@ -0,0 +1,327 @@ +import { useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { X, ChevronDown, ChevronUp, Calendar } from 'lucide-react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { tasksApi } from '../api/tasks' +import clsx from 'clsx' +import { format, addDays } from 'date-fns' + +const COLORS = [ + '#6B7280', '#6366f1', '#8b5cf6', '#d946ef', '#ec4899', + '#f43f5e', '#f97316', '#eab308', '#22c55e', '#0ea5e9', +] + +const ICON_CATEGORIES = [ + { name: 'Продуктивность', icons: ['📋', '📝', '✅', '📌', '🎯', '💡', '📅', '⏰'] }, + { name: 'Работа', icons: ['💼', '💻', '📧', '📞', '📊', '📈', '🖥️', '⌨️'] }, + { name: 'Дом', icons: ['🏠', '🧹', '🧺', '🍳', '🛒', '🔧', '🪴', '🛋️'] }, + { name: 'Финансы', icons: ['💰', '💳', '📊', '💵', '🏦', '🧾'] }, + { name: 'Здоровье', icons: ['💊', '🏃', '🧘', '💪', '🩺', '🦷'] }, + { name: 'Разное', icons: ['⭐', '🎁', '📦', '✈️', '🚗', '📷', '🎉'] }, +] + +const PRIORITIES = [ + { value: 0, label: 'Без приоритета', color: 'bg-gray-100 text-gray-600' }, + { value: 1, label: 'Низкий', color: 'bg-blue-100 text-blue-700' }, + { value: 2, label: 'Средний', color: 'bg-yellow-100 text-yellow-700' }, + { value: 3, label: 'Высокий', color: 'bg-red-100 text-red-700' }, +] + +export default function CreateTaskModal({ open, onClose, defaultDueDate = null }) { + const today = format(new Date(), 'yyyy-MM-dd') + const tomorrow = format(addDays(new Date(), 1), 'yyyy-MM-dd') + + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') + const [color, setColor] = useState(COLORS[0]) + const [icon, setIcon] = useState('📋') + const [dueDate, setDueDate] = useState(defaultDueDate || today) + const [priority, setPriority] = useState(0) + const [error, setError] = useState('') + const [showAllIcons, setShowAllIcons] = useState(false) + + const queryClient = useQueryClient() + + const mutation = useMutation({ + mutationFn: (data) => tasksApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tasks'] }) + queryClient.invalidateQueries({ queryKey: ['tasks-today'] }) + handleClose() + }, + onError: (err) => { + setError(err.response?.data?.error || 'Ошибка создания') + }, + }) + + const handleClose = () => { + setTitle('') + setDescription('') + setColor(COLORS[0]) + setIcon('📋') + setDueDate(defaultDueDate || today) + setPriority(0) + setError('') + setShowAllIcons(false) + onClose() + } + + const handleSubmit = (e) => { + e.preventDefault() + if (!title.trim()) { + setError('Введи название задачи') + return + } + + mutation.mutate({ + title, + description, + color, + icon, + due_date: dueDate || null, + priority, + }) + } + + const popularIcons = ['📋', '📝', '✅', '🎯', '💼', '🏠', '💰', '📞'] + + return ( + + {open && ( + <> + + +
+
+

Новая задача

+ +
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setTitle(e.target.value)} + className="input" + placeholder="Что нужно сделать?" + autoFocus + /> +
+ +
+ +