From 2a4a28ee691617ca08f39362d5b36bc2be8ca5ed Mon Sep 17 00:00:00 2001 From: Vladyslav <46872670+virus231@users.noreply.github.com> Date: Wed, 17 Sep 2025 18:01:25 +0200 Subject: [PATCH] feat: improvements --- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 14 + frontend/src/app/layout.tsx | 2 + frontend/src/app/posts/page.tsx | 235 ++++---- frontend/src/app/profile/page.tsx | 556 ++++++++++++++++++ frontend/src/components/layout/app-layout.tsx | 50 ++ frontend/src/components/layout/header.tsx | 185 ++++++ .../components/providers/toast-provider.tsx | 22 + .../src/components/ui/loading-skeletons.tsx | 186 ++++++ frontend/src/components/ui/skeleton.tsx | 13 + 10 files changed, 1134 insertions(+), 130 deletions(-) create mode 100644 frontend/src/app/profile/page.tsx create mode 100644 frontend/src/components/layout/app-layout.tsx create mode 100644 frontend/src/components/layout/header.tsx create mode 100644 frontend/src/components/providers/toast-provider.tsx create mode 100644 frontend/src/components/ui/loading-skeletons.tsx create mode 100644 frontend/src/components/ui/skeleton.tsx diff --git a/frontend/package.json b/frontend/package.json index a44fe2d..b1449b9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-hook-form": "^7.62.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "zod": "^4.1.9" }, diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index c234bf9..d75bc36 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: react-hook-form: specifier: ^7.62.0 version: 7.62.0(react@19.1.0) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) tailwind-merge: specifier: ^3.3.1 version: 3.3.1 @@ -796,6 +799,12 @@ packages: simple-swizzle@0.2.4: resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1455,6 +1464,11 @@ snapshots: is-arrayish: 0.3.4 optional: true + sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + source-map-js@1.2.1: {} styled-jsx@5.1.6(react@19.1.0): diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 61ef3a0..61d020d 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { QueryProvider } from "@/components/providers/query-provider"; import { AuthProvider } from "@/contexts/auth-context"; +import { ToastProvider } from "@/components/providers/toast-provider"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -32,6 +33,7 @@ export default function RootLayout({ {children} + diff --git a/frontend/src/app/posts/page.tsx b/frontend/src/app/posts/page.tsx index b18a40f..4d71d72 100644 --- a/frontend/src/app/posts/page.tsx +++ b/frontend/src/app/posts/page.tsx @@ -5,15 +5,15 @@ import { useRouter } from 'next/navigation'; import { useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; -import { useLogout } from '@/hooks/use-auth'; +import { AppLayout } from '@/components/layout/app-layout'; +import { PostsListSkeleton } from '@/components/ui/loading-skeletons'; import { usePosts } from '@/hooks/use-posts'; import { formatDistanceToNow } from 'date-fns'; import { uk } from 'date-fns/locale'; export default function PostsPage() { - const { isAuthenticated, isLoading, user } = useAuth(); + const { isAuthenticated, isLoading } = useAuth(); const router = useRouter(); - const logout = useLogout(); const { data: postsData, isLoading: postsLoading, error: postsError } = usePosts(); useEffect(() => { @@ -22,7 +22,7 @@ export default function PostsPage() { } }, [isAuthenticated, isLoading, router]); - if (isLoading || postsLoading) { + if (isLoading) { return (
@@ -34,141 +34,116 @@ export default function PostsPage() { return null; // Will redirect } - const posts = postsData?.posts || []; - - return ( -
- {/* Header */} -
-
-
-
- - | - Всі пости -
-
- - - - {user?.name || user?.email} - - -
-
-
-
- - {/* Main Content */} -
+ if (postsLoading) { + return ( +

Всі пости

-

- {postsData?.total ? `Знайдено ${postsData.total} ${postsData.total === 1 ? 'пост' : 'постів'}` : 'Завантаження...'} -

+

Завантаження...

+ +
+ ); + } + + const posts = postsData?.posts || []; + + return ( + +
+
+

Всі пости

+

+ {postsData?.total ? `Знайдено ${postsData.total} ${postsData.total === 1 ? 'пост' : 'постів'}` : 'Завантаження...'} +

+
+ +
- {postsError && ( - - -

- Помилка завантаження постів: {(postsError as any)?.response?.data?.message || 'Невідома помилка'} -

-
-
- )} + {postsError && ( + + +

+ Помилка завантаження постів: {(postsError as any)?.response?.data?.message || 'Невідома помилка'} +

+
+
+ )} - {/* Posts Grid */} - {posts.length === 0 && !postsError ? ( - - -

- Постів поки що немає -

-

- Станьте першим, хто поділиться своїми думками! -

- -
-
- ) : ( -
- {posts.map((post) => ( - router.push(`/posts/${post.id}`)} - > - - - {post.title} - - {post.description && ( - - {post.description} - - )} - - -
- {post.content.substring(0, 150)} - {post.content.length > 150 && '...'} -
-
- -
-

- {post.author.name || post.author.email} -

-

- {formatDistanceToNow(new Date(post.createdAt), { - addSuffix: true, - locale: uk - })} -

-
- -
-
- ))} -
- )} -
-
+ {/* Posts Grid */} + {posts.length === 0 && !postsError ? ( + + +

+ Постів поки що немає +

+

+ Станьте першим, хто поділиться своїми думками! +

+ +
+
+ ) : ( +
+ {posts.map((post) => ( + router.push(`/posts/${post.id}`)} + > + + + {post.title} + + {post.description && ( + + {post.description} + + )} + + +
+ {post.content.substring(0, 120)} + {post.content.length > 120 && '...'} +
+
+ +
+

+ {post.author.name || post.author.email} +

+

+ {formatDistanceToNow(new Date(post.createdAt), { + addSuffix: true, + locale: uk + })} +

+
+ +
+
+ ))} +
+ )} + ); } \ No newline at end of file diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx new file mode 100644 index 0000000..6b2d2dd --- /dev/null +++ b/frontend/src/app/profile/page.tsx @@ -0,0 +1,556 @@ +'use client'; + +import { useState } from 'react'; +import { useAuth } from '@/contexts/auth-context'; +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; + +import { useLogout, useCurrentUser, useUpdateUser, useDeleteUser } from '@/hooks/use-auth'; +import { ArrowLeft, User, Mail, Calendar, FileText, Save, Trash2, Eye, EyeOff } from 'lucide-react'; +import { formatDistanceToNow, format } from 'date-fns'; +import { uk } from 'date-fns/locale'; + +const updateProfileSchema = z.object({ + name: z.string().min(2, 'Ім\'я повинно містити мінімум 2 символи').max(50, 'Ім\'я занадто довге').optional().or(z.literal('')), + email: z.string().email('Введіть правильний email'), +}); + +const changePasswordSchema = z.object({ + currentPassword: z.string().min(1, 'Введіть поточний пароль'), + newPassword: z.string().min(6, 'Новий пароль повинен містити мінімум 6 символів'), + confirmPassword: z.string().min(6, 'Підтвердження пароля обов\'язкове'), +}).refine((data) => data.newPassword === data.confirmPassword, { + message: 'Паролі не співпадають', + path: ['confirmPassword'], +}); + +const deleteAccountSchema = z.object({ + password: z.string().min(1, 'Введіть пароль для підтвердження'), + confirmation: z.string().refine((val) => val === 'ВИДАЛИТИ', { + message: 'Введіть "ВИДАЛИТИ" для підтвердження', + }), +}); + +type UpdateProfileFormValues = z.infer; +type ChangePasswordFormValues = z.infer; +type DeleteAccountFormValues = z.infer; + +export default function ProfilePage() { + const { isAuthenticated, isLoading } = useAuth(); + const router = useRouter(); + const logout = useLogout(); + const { data: currentUser, isLoading: userLoading } = useCurrentUser(); + const updateUserMutation = useUpdateUser(); + const deleteUserMutation = useDeleteUser(); + + const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'danger'>('profile'); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [showPasswords, setShowPasswords] = useState({ + current: false, + new: false, + confirm: false, + delete: false, + }); + + const profileForm = useForm({ + resolver: zodResolver(updateProfileSchema), + defaultValues: { + name: '', + email: '', + }, + }); + + const passwordForm = useForm({ + resolver: zodResolver(changePasswordSchema), + defaultValues: { + currentPassword: '', + newPassword: '', + confirmPassword: '', + }, + }); + + const deleteForm = useForm({ + resolver: zodResolver(deleteAccountSchema), + defaultValues: { + password: '', + confirmation: '', + }, + }); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push('/auth/login'); + } + }, [isAuthenticated, isLoading, router]); + + useEffect(() => { + if (currentUser) { + profileForm.reset({ + name: currentUser.name || '', + email: currentUser.email, + }); + } + }, [currentUser, profileForm]); + + const onUpdateProfile = async (data: UpdateProfileFormValues) => { + try { + setError(''); + setSuccess(''); + + const updateData: any = { + email: data.email, + }; + + if (data.name && data.name.trim()) { + updateData.name = data.name.trim(); + } + + await updateUserMutation.mutateAsync(updateData); + setSuccess('Профіль успішно оновлено!'); + } catch (err: any) { + const errorMessage = err.response?.data?.message || 'Помилка оновлення профілю'; + setError(errorMessage); + } + }; + + const onChangePassword = async (data: ChangePasswordFormValues) => { + try { + setError(''); + setSuccess(''); + + await updateUserMutation.mutateAsync({ + password: data.newPassword, + currentPassword: data.currentPassword, + }); + + setSuccess('Пароль успішно змінено!'); + passwordForm.reset(); + } catch (err: any) { + const errorMessage = err.response?.data?.message || 'Помилка зміни пароля'; + setError(errorMessage); + } + }; + + const onDeleteAccount = async (data: DeleteAccountFormValues) => { + try { + setError(''); + + if (window.confirm('Ви впевнені, що хочете видалити свій аккаунт? Ця дія незворотна!')) { + await deleteUserMutation.mutateAsync(data.password); + // The hook will handle logout and navigation + } + } catch (err: any) { + const errorMessage = err.response?.data?.message || 'Помилка видалення аккаунту'; + setError(errorMessage); + } + }; + + if (isLoading || userLoading) { + return ( +
+
+
+ ); + } + + if (!isAuthenticated || !currentUser) { + return null; // Will redirect + } + + return ( +
+ {/* Header */} +
+
+
+
+ + | + Профіль +
+
+ + + +
+
+
+
+ + {/* Main Content */} +
+ {/* Back Button */} +
+ +
+ +
+ {/* Profile Summary Sidebar */} +
+ + +
+
+ +
+

+ {currentUser.name || 'Користувач'} +

+

{currentUser.email}

+ +
+
+ + + Реєстрація {format(new Date(currentUser.createdAt), 'MMMM yyyy', { locale: uk })} + +
+ {currentUser._count && ( +
+ + {currentUser._count.posts} постів +
+ )} +
+
+
+
+ + {/* Navigation */} + + + + + +
+ + {/* Main Content Area */} +
+ {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} +
+ )} + + {/* Profile Tab */} + {activeTab === 'profile' && ( + + + Редагувати профіль + + Оновіть свою особисту інформацію + + + +
+ + ( + + Ім'я + + + + + + )} + /> + + ( + + Email + + + + + + )} + /> + + + + +
+
+ )} + + {/* Password Tab */} + {activeTab === 'password' && ( + + + Зміна пароля + + Оновіть свій пароль для безпеки аккаунту + + + +
+ + ( + + Поточний пароль + +
+ + +
+
+ +
+ )} + /> + + ( + + Новий пароль + +
+ + +
+
+ +
+ )} + /> + + ( + + Підтвердження нового пароля + +
+ + +
+
+ +
+ )} + /> + + + + +
+
+ )} + + {/* Danger Zone Tab */} + {activeTab === 'danger' && ( + + + Небезпечна зона + + Видалення аккаунту є незворотним. Всі ваші пости та дані будуть втрачені назавжди. + + + +
+ + ( + + Підтвердження пароля + +
+ + +
+
+ +
+ )} + /> + + ( + + Введіть "ВИДАЛИТИ" для підтвердження + + + + + + )} + /> + + + + +
+
+ )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/layout/app-layout.tsx b/frontend/src/components/layout/app-layout.tsx new file mode 100644 index 0000000..cb71c62 --- /dev/null +++ b/frontend/src/components/layout/app-layout.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { ReactNode } from 'react'; +import { Header } from './header'; + +interface AppLayoutProps { + children: ReactNode; + currentPage?: string; + showBackButton?: boolean; + backButtonText?: string; + backButtonHref?: string; + maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '4xl' | '7xl' | 'full'; + className?: string; +} + +const maxWidthClasses = { + sm: 'max-w-sm', + md: 'max-w-md', + lg: 'max-w-lg', + xl: 'max-w-xl', + '2xl': 'max-w-2xl', + '4xl': 'max-w-4xl', + '7xl': 'max-w-7xl', + full: 'max-w-full', +}; + +export function AppLayout({ + children, + currentPage, + showBackButton, + backButtonText, + backButtonHref, + maxWidth = '7xl', + className = '' +}: AppLayoutProps) { + return ( +
+
+ +
+ {children} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/layout/header.tsx b/frontend/src/components/layout/header.tsx new file mode 100644 index 0000000..8a2dbb9 --- /dev/null +++ b/frontend/src/components/layout/header.tsx @@ -0,0 +1,185 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { useAuth } from '@/contexts/auth-context'; +import { useLogout } from '@/hooks/use-auth'; +import { + PenTool, + User, + LogOut, + FileText, + Menu, + X +} from 'lucide-react'; +import { useState } from 'react'; + +interface HeaderProps { + currentPage?: string; + showBackButton?: boolean; + backButtonText?: string; + backButtonHref?: string; +} + +export function Header({ + currentPage, + showBackButton = false, + backButtonText = 'Назад', + backButtonHref = '/posts' +}: HeaderProps) { + const router = useRouter(); + const { user } = useAuth(); + const logout = useLogout(); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + + const handleLogoClick = () => { + router.push('/posts'); + }; + + const handleBackClick = () => { + router.push(backButtonHref); + }; + + const navigationItems = [ + { + label: 'Пости', + href: '/posts', + icon: FileText, + active: currentPage === 'posts', + }, + { + label: 'Створити', + href: '/posts/create', + icon: PenTool, + active: currentPage === 'create', + }, + { + label: 'Профіль', + href: '/profile', + icon: User, + active: currentPage === 'profile', + }, + ]; + + return ( +
+
+
+ {/* Left Section */} +
+ + + {currentPage && ( + <> + | + {currentPage} + + )} + + {showBackButton && ( + + )} +
+ + {/* Desktop Navigation */} +
+ {navigationItems.map((item) => ( + + ))} + +
+ + {user?.name || user?.email} + + +
+
+ + {/* Mobile Menu Button */} +
+ +
+
+ + {/* Mobile Navigation */} + {mobileMenuOpen && ( +
+
+ {navigationItems.map((item) => ( + + ))} + +
+
+ {user?.name || user?.email} +
+ +
+
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/providers/toast-provider.tsx b/frontend/src/components/providers/toast-provider.tsx new file mode 100644 index 0000000..96f4f76 --- /dev/null +++ b/frontend/src/components/providers/toast-provider.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { Toaster } from 'sonner'; + +export function ToastProvider() { + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/components/ui/loading-skeletons.tsx b/frontend/src/components/ui/loading-skeletons.tsx new file mode 100644 index 0000000..8e8a66e --- /dev/null +++ b/frontend/src/components/ui/loading-skeletons.tsx @@ -0,0 +1,186 @@ +import { Skeleton } from '@/components/ui/skeleton'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; + +export function PostCardSkeleton() { + return ( + + + + + + + +
+ + + +
+
+
+ + +
+ +
+
+
+ ); +} + +export function PostDetailSkeleton() { + return ( +
+ {/* Post Header */} +
+ + + + + {/* Post Meta */} +
+
+ + +
+ +
+
+ + {/* Post Body */} +
+
+ + + + + + + +
+
+ + {/* Post Footer */} +
+
+ +
+ + +
+
+
+
+ ); +} + +export function PostsListSkeleton() { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ); +} + +export function ProfileSkeleton() { + return ( +
+ {/* Sidebar Skeleton */} +
+ + +
+ + + + +
+ + +
+
+
+
+ + + +
+ + + +
+
+
+
+ + {/* Main Content Skeleton */} +
+ + + + + + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+ ); +} + +export function PageLoadingSkeleton() { + return ( +
+ {/* Header Skeleton */} +
+
+
+
+ + +
+
+ + + +
+
+
+
+ + {/* Content Skeleton */} +
+
+ + +
+ {Array.from({ length: 6 }).map((_, i) => ( + + + + + + + + + + + ))} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/ui/skeleton.tsx b/frontend/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..32ea0ef --- /dev/null +++ b/frontend/src/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Skeleton }