diff --git a/.releaserc.json b/.releaserc.json index d885024..b51e9d5 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -115,16 +115,9 @@ [ "@semantic-release/github", { - "assets": [ - { - "path": "backend/dist/**/*", - "label": "Backend Build" - }, - { - "path": "frontend/dist/**/*", - "label": "Frontend Build" - } - ] + "failComment": false, + "failTitle": false, + "successComment": false } ], [ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7641c5a..b1f4b9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,132 @@ -## [1.0.5](https://github.com/virus231/tech-stack/compare/v1.0.4...v1.0.5) (2025-09-16) +## [1.1.0-beta.22](https://github.com/virus231/tech-stack/compare/v1.1.0-beta.21...v1.1.0-beta.22) (2025-09-17) + +### ♻️ Chores + +* **release:** 1.0.5 [skip ci] ([6a443fa](https://github.com/virus231/tech-stack/commit/6a443fa4447ee42a9dd1a67d88fcd42e84f0ee35)) + +## [1.1.0-beta.20](https://github.com/virus231/tech-stack/compare/v1.1.0-beta.19...v1.1.0-beta.20) (2025-09-17) + +### ✨ Features + +* edit post ([13806c2](https://github.com/virus231/tech-stack/commit/13806c2e19582b013425193cda25b157d8eaf245)) + +## [1.1.0-beta.19](https://github.com/virus231/tech-stack/compare/v1.1.0-beta.18...v1.1.0-beta.19) (2025-09-17) + +### 🐛 Bug Fixes + +* static post page ([f56e590](https://github.com/virus231/tech-stack/commit/f56e5907f80bf419c9df3c5f759e4decc9be1b5f)) + +## [1.1.0-beta.18](https://github.com/virus231/tech-stack/compare/v1.1.0-beta.17...v1.1.0-beta.18) (2025-09-17) + +### 🐛 Bug Fixes + +* error page promiseo ([341c60f](https://github.com/virus231/tech-stack/commit/341c60fc61a9b059eb837e1be40767201434045a)) + +## [1.1.0-beta.17](https://github.com/virus231/tech-stack/compare/v1.1.0-beta.16...v1.1.0-beta.17) (2025-09-17) + +### 🐛 Bug Fixes + +* error env var ([571e00b](https://github.com/virus231/tech-stack/commit/571e00b96548769d4a31f5e032cabcdf693607a5)) + +## [1.1.0-beta.16](https://github.com/virus231/tech-stack/compare/v1.1.0-beta.15...v1.1.0-beta.16) (2025-09-17) + +### ✨ Features + +* improvements ([2a4a28e](https://github.com/virus231/tech-stack/commit/2a4a28ee691617ca08f39362d5b36bc2be8ca5ed)) + +## [1.1.0-beta.15](https://github.com/virus231/tech-stack/compare/v1.1.0-beta.14...v1.1.0-beta.15) (2025-09-17) + +### ✨ Features + +* create post form ([43ef77c](https://github.com/virus231/tech-stack/commit/43ef77cf433bbf5f787ba0a8f1a6f65fe08c501e)) + +## [1.1.0-beta.14](https://github.com/virus231/tech-stack/compare/v1.1.0-beta.13...v1.1.0-beta.14) (2025-09-17) + +### ✨ Features + +* posts ui CRUD ([dab04b3](https://github.com/virus231/tech-stack/commit/dab04b3e1c4e2a5c361609f09b4acb4956ad9924)) + +## [1.1.0-beta.13](https://github.com/virus231/tech-stack/compare/v1.1.0-beta.12...v1.1.0-beta.13) (2025-09-17) + +### ✨ Features + +* shadcn setup, auth setup with tanstack query and axios ([d2b9ce5](https://github.com/virus231/tech-stack/commit/d2b9ce5002917ea1f25dc1b0332c587cb49b703f)) + +## [1.1.0-beta.12](https://github.com/virus231/tech-stack/compare/v1.1.0-beta.11...v1.1.0-beta.12) (2025-09-17) + +### ✨ Features + +* users api ([fab05d5](https://github.com/virus231/tech-stack/commit/fab05d5bedea427442a8cc78d384f0e25d1a1cf0)) + +## [1.1.0-beta.11](https://github.com/virus231/tech-stack/compare/v1.1.0-beta.10...v1.1.0-beta.11) (2025-09-17) + +### ✨ Features + +* posts api ([f97fad7](https://github.com/virus231/tech-stack/commit/f97fad778935223a4392c8fd7a513c3580700056)) + +## [1.1.0-beta.10](https://github.com/virus231/tech-stack/compare/v1.1.0-beta.9...v1.1.0-beta.10) (2025-09-17) + +### 🐛 Bug Fixes + +* releaser config ([e315985](https://github.com/virus231/tech-stack/commit/e3159854bdf33b155920a3a36b95c8ca1f15eadc)) + +## [1.1.0-beta.9](https://github.com/virus231/tech-stack/compare/v1.1.0-beta.8...v1.1.0-beta.9) (2025-09-17) + +### 🐛 Bug Fixes + +* change folder ([58aa7b0](https://github.com/virus231/tech-stack/commit/58aa7b03a8e4fd7e465a5b3ebce5d3cdae5ce4ac)) + +## [1.1.0-beta.8](https://github.com/virus231/tech-stack/compare/v1.1.0-beta.7...v1.1.0-beta.8) (2025-09-16) + +### 🐛 Bug Fixes + +* api path url ([492e246](https://github.com/virus231/tech-stack/commit/492e2461c6fc095242c49002109837615df35308)) + +## [1.1.0-beta.7](https://github.com/virus231/tech-stack/compare/v1.1.0-beta.6...v1.1.0-beta.7) (2025-09-16) + +### 🐛 Bug Fixes + +* api ([6ff87b1](https://github.com/virus231/tech-stack/commit/6ff87b174ce711deca43853a879458d72943a6d3)) + +## [1.1.0-beta.6](https://github.com/virus231/tech-stack/compare/v1.1.0-beta.5...v1.1.0-beta.6) (2025-09-16) + +### 🚨 Tests + +* fix api ([2f641b5](https://github.com/virus231/tech-stack/commit/2f641b5a3ee320a1dc72594459c52382088d15a8)) + +## [1.1.0-beta.5](https://github.com/virus231/tech-stack/compare/v1.1.0-beta.4...v1.1.0-beta.5) (2025-09-16) + +### 🚨 Tests + +* fix api ([28c4ae3](https://github.com/virus231/tech-stack/commit/28c4ae364c1ef8ec09f65b83cb63ecf17ba15dca)) + +## [1.1.0-beta.4](https://github.com/virus231/tech-stack/compare/v1.1.0-beta.3...v1.1.0-beta.4) (2025-09-16) + +### 🚨 Tests + +* api request ([7b46172](https://github.com/virus231/tech-stack/commit/7b4617253321b0b65d8def99d0c66957d4c94eb3)) + +## [1.1.0-beta.3](https://github.com/virus231/tech-stack/compare/v1.1.0-beta.2...v1.1.0-beta.3) (2025-09-16) + +### ✨ Features + +* connect full Express app to Vercel for production auth ([81a9534](https://github.com/virus231/tech-stack/commit/81a953462b343fba7cfd4c4e4d2c181394f06bf3)) + +## [1.1.0-beta.2](https://github.com/virus231/tech-stack/compare/v1.1.0-beta.1...v1.1.0-beta.2) (2025-09-16) + +### ✨ Features + +* auth endpoints ([acefc83](https://github.com/virus231/tech-stack/commit/acefc83ea98a610d05f633845ac5ae51653c761c)) + +### 🐛 Bug Fixes + +* type errors ([d4156b4](https://github.com/virus231/tech-stack/commit/d4156b47ee468b7ae2e352ff8767512a1204c51b)) + +## [1.1.0-beta.1](https://github.com/virus231/tech-stack/compare/v1.0.4...v1.1.0-beta.1) (2025-09-16) + +### ✨ Features + +* database init ([e160fac](https://github.com/virus231/tech-stack/commit/e160fac0ab056e27d72b0097dc3729194a8ef40c)) ### 🐛 Bug Fixes diff --git a/backend/api/index.ts b/backend/api/index.ts deleted file mode 100644 index ad7bf28..0000000 --- a/backend/api/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { VercelRequest, VercelResponse } from '@vercel/node'; - -export default function handler(req: VercelRequest, res: VercelResponse) { - // Health check - if (req.url === '/api/health') { - return res.status(200).json({ - status: 'OK', - timestamp: new Date().toISOString(), - environment: 'production', - }); - } - - // Default API response - return res.json({ - message: 'API is working', - method: req.method, - url: req.url - }); -} \ No newline at end of file diff --git a/backend/index.ts b/backend/index.ts deleted file mode 100644 index e69de29..0000000 diff --git a/backend/package.json b/backend/package.json index 2f781be..eda11c4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,8 +13,7 @@ "prisma:migrate": "prisma migrate dev", "prisma:migrate:prod": "ts-node prisma/migrate-prod.ts", "prisma:studio": "prisma studio", - "vercel-build": "pnpm prisma:generate && pnpm build", - "test": "echo \"Error: no test specified\" && exit 1" + "vercel-build": "pnpm prisma:generate && tsc && tsc-alias" }, "keywords": [], "author": "", @@ -23,20 +22,25 @@ "dependencies": { "@prisma/client": "^6.16.1", "@vercel/node": "^3.0.31", + "bcryptjs": "^3.0.2", "cors": "^2.8.5", "dotenv": "^17.2.2", "express": "^5.1.0", "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.2", "morgan": "^1.10.1" }, "devDependencies": { + "@types/bcryptjs": "^3.0.0", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", + "@types/jsonwebtoken": "^9.0.10", "@types/morgan": "^1.9.10", "@types/node": "^24.5.0", "nodemon": "^3.1.10", "prisma": "^6.16.1", "ts-node": "^10.9.2", + "tsc-alias": "^1.8.8", "tsconfig-paths": "^4.2.0", "typescript": "^5.9.2" } diff --git a/backend/prisma/migrations/20250916210727_init/migration.sql b/backend/prisma/migrations/20250916210727_init/migration.sql new file mode 100644 index 0000000..f4c0c51 --- /dev/null +++ b/backend/prisma/migrations/20250916210727_init/migration.sql @@ -0,0 +1,30 @@ +-- CreateTable +CREATE TABLE "public"."User" ( + "id" SERIAL NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT, + "password" TEXT NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."Post" ( + "id" SERIAL NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "description" TEXT, + "authorId" INTEGER NOT NULL, + + CONSTRAINT "Post_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "public"."User"("email"); + +-- AddForeignKey +ALTER TABLE "public"."Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ee282c7..8a76ea4 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1,9 +1,6 @@ // This is your Prisma schema file, // learn more about it in the docs: https://pris.ly/d/prisma-schema -// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? -// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init - generator client { provider = "prisma-client-js" } @@ -12,3 +9,30 @@ datasource db { provider = "postgresql" url = env("DATABASE_URL") } + +// User model for authentication +model User { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + email String @unique + name String? + password String + + // Relations + posts Post[] +} + +// Post model for blog posts +model Post { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + title String + content String + description String? + + // Relations + authorId Int + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) +} diff --git a/backend/src/app.ts b/backend/src/app.ts index 24faacc..f05a512 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,8 +1,11 @@ -import { config } from "@/config/config"; +import { config } from "./config/config"; import cors from "cors"; import express, { type Express } from "express"; import helmet from "helmet"; import morgan from "morgan"; +import authRoutes from "./routes/auth"; +import postsRoutes from "./routes/posts"; +import usersRoutes from "./routes/users"; const app: Express = express(); @@ -25,7 +28,7 @@ app.use(express.json({ limit: "10mb" })); app.use(express.urlencoded({ extended: true, limit: "10mb" })); // Health check endpoint -app.get("/health", (_req, res) => { +app.get("/api/health", (_req, res) => { res.status(200).json({ status: "OK", timestamp: new Date().toISOString(), @@ -33,6 +36,15 @@ app.get("/health", (_req, res) => { }); }); +// Auth routes +app.use("/api/auth", authRoutes); + +// Posts routes +app.use("/api/posts", postsRoutes); + +// Users routes +app.use("/api/users", usersRoutes); + // API routes app.use("/api", (_req, res) => { res.json({ message: "API is working" }); diff --git a/backend/src/config/config.ts b/backend/src/config/config.ts index c923178..9b031c7 100644 --- a/backend/src/config/config.ts +++ b/backend/src/config/config.ts @@ -31,3 +31,8 @@ for (const envVar of requiredEnvVars) { throw new Error(`Missing required environment variable: ${envVar}`); } } + +// Warn if using fallback JWT secret in production +if (!process.env.JWT_SECRET && config.nodeEnv === "production") { + console.warn("⚠️ Warning: Using fallback JWT secret in production! Please set JWT_SECRET environment variable."); +} diff --git a/backend/src/controllers/auth.ts b/backend/src/controllers/auth.ts new file mode 100644 index 0000000..15ee568 --- /dev/null +++ b/backend/src/controllers/auth.ts @@ -0,0 +1,148 @@ +import { Request, Response } from 'express'; +import { PrismaClient } from '@prisma/client'; +import { generateToken, hashPassword, comparePassword } from '../utils/auth'; +import { RegisterRequest, LoginRequest, AuthResponse } from '../types/auth'; + +const prisma = new PrismaClient(); + +/** + * Register new user + * POST /auth/register + */ +export const register = async (req: Request, res: Response): Promise => { + try { + const { email, password }: RegisterRequest = req.body; + + // Validate input + if (!email || !password) { + res.status(400).json({ + error: 'Validation error', + message: 'Email and password are required' + }); + return; + } + + if (password.length < 6) { + res.status(400).json({ + error: 'Validation error', + message: 'Password must be at least 6 characters long' + }); + return; + } + + // Check if user already exists + const existingUser = await prisma.user.findUnique({ + where: { email } + }); + + if (existingUser) { + res.status(409).json({ + error: 'User already exists', + message: 'A user with this email already exists' + }); + return; + } + + // Hash password and create user + const hashedPassword = await hashPassword(password); + + const user = await prisma.user.create({ + data: { + email, + password: hashedPassword, + name: null + } + }); + + // Generate token + const token = generateToken({ + userId: user.id, + email: user.email + }); + + const response: AuthResponse = { + user: { + id: user.id, + email: user.email, + name: user.name, + createdAt: user.createdAt + }, + token + }; + + res.status(201).json(response); + } catch (error) { + console.error('Registration error:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to create user' + }); + } +}; + +/** + * Login user + * POST /auth/login + */ +export const login = async (req: Request, res: Response): Promise => { + try { + const { email, password }: LoginRequest = req.body; + + // Validate input + if (!email || !password) { + res.status(400).json({ + error: 'Validation error', + message: 'Email and password are required' + }); + return; + } + + // Find user + const user = await prisma.user.findUnique({ + where: { email } + }); + + if (!user) { + res.status(401).json({ + error: 'Authentication failed', + message: 'Invalid email or password' + }); + return; + } + + // Verify password + const isValidPassword = await comparePassword(password, user.password); + + if (!isValidPassword) { + res.status(401).json({ + error: 'Authentication failed', + message: 'Invalid email or password' + }); + return; + } + + // Generate token + const token = generateToken({ + userId: user.id, + email: user.email + }); + + const response: AuthResponse = { + user: { + id: user.id, + email: user.email, + name: user.name, + createdAt: user.createdAt + }, + token + }; + + res.status(200).json(response); + } catch (error) { + console.error('Login error:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to authenticate user' + }); + } +}; \ No newline at end of file diff --git a/backend/src/controllers/posts.ts b/backend/src/controllers/posts.ts new file mode 100644 index 0000000..8e84e62 --- /dev/null +++ b/backend/src/controllers/posts.ts @@ -0,0 +1,282 @@ +import { Request, Response } from 'express'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +interface AuthRequest extends Request { + user?: { + userId: number; + email: string; + }; +} + +interface CreatePostRequest { + title: string; + content: string; + description?: string; +} + +interface UpdatePostRequest { + title?: string; + content?: string; + description?: string; +} + +/** + * Get all posts + * GET /posts + */ +export const getAllPosts = async (req: Request, res: Response): Promise => { + try { + const posts = await prisma.post.findMany({ + include: { + author: { + select: { + id: true, + name: true, + email: true, + createdAt: true + } + } + }, + orderBy: { + createdAt: 'desc' + } + }); + + res.status(200).json({ + posts, + total: posts.length + }); + } catch (error) { + console.error('Get all posts error:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to fetch posts' + }); + } +}; + +/** + * Get post by ID + * GET /posts/:id + */ +export const getPostById = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + const postId = parseInt(id, 10); + + if (isNaN(postId)) { + res.status(400).json({ + error: 'Validation error', + message: 'Invalid post ID' + }); + return; + } + + const post = await prisma.post.findUnique({ + where: { id: postId }, + include: { + author: { + select: { + id: true, + name: true, + email: true, + createdAt: true + } + } + } + }); + + if (!post) { + res.status(404).json({ + error: 'Post not found', + message: 'Post with this ID does not exist' + }); + return; + } + + res.status(200).json(post); + } catch (error) { + console.error('Get post by ID error:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to fetch post' + }); + } +}; + +/** + * Create new post + * POST /posts + * Requires authentication + */ +export const createPost = async (req: AuthRequest, res: Response): Promise => { + try { + if (!req.user) { + res.status(401).json({ + error: 'Authentication required', + message: 'You must be logged in to create a post' + }); + return; + } + + const { title, content, description }: CreatePostRequest = req.body; + + // Validate input + if (!title || !content) { + res.status(400).json({ + error: 'Validation error', + message: 'Title and content are required' + }); + return; + } + + if (title.trim().length < 3) { + res.status(400).json({ + error: 'Validation error', + message: 'Title must be at least 3 characters long' + }); + return; + } + + if (content.trim().length < 10) { + res.status(400).json({ + error: 'Validation error', + message: 'Content must be at least 10 characters long' + }); + return; + } + + // Create post + const post = await prisma.post.create({ + data: { + title: title.trim(), + content: content.trim(), + description: description?.trim() || null, + authorId: req.user.userId + }, + include: { + author: { + select: { + id: true, + name: true, + email: true, + createdAt: true + } + } + } + }); + + res.status(201).json(post); + } catch (error) { + console.error('Create post error:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to create post' + }); + } +}; + +/** + * Update post by ID + * PUT /posts/:id + * Requires authentication and ownership + */ +export const updatePost = async (req: AuthRequest, res: Response): Promise => { + try { + if (!req.user) { + res.status(401).json({ + error: 'Authentication required', + message: 'You must be logged in to update a post' + }); + return; + } + + const { id } = req.params; + const postId = parseInt(id, 10); + + if (isNaN(postId)) { + res.status(400).json({ + error: 'Validation error', + message: 'Invalid post ID' + }); + return; + } + + // Check if post exists and user owns it + const existingPost = await prisma.post.findUnique({ + where: { id: postId }, + select: { id: true, authorId: true } + }); + + if (!existingPost) { + res.status(404).json({ + error: 'Post not found', + message: 'Post with this ID does not exist' + }); + return; + } + + if (existingPost.authorId !== req.user.userId) { + res.status(403).json({ + error: 'Forbidden', + message: 'You can only update your own posts' + }); + return; + } + + const { title, content, description }: UpdatePostRequest = req.body; + + // Validate input if provided + if (title !== undefined) { + if (!title || title.trim().length < 3) { + res.status(400).json({ + error: 'Validation error', + message: 'Title must be at least 3 characters long' + }); + return; + } + } + + if (content !== undefined) { + if (!content || content.trim().length < 10) { + res.status(400).json({ + error: 'Validation error', + message: 'Content must be at least 10 characters long' + }); + return; + } + } + + // Prepare update data + const updateData: any = {}; + if (title !== undefined) updateData.title = title.trim(); + if (content !== undefined) updateData.content = content.trim(); + if (description !== undefined) updateData.description = description?.trim() || null; + + // Update post + const updatedPost = await prisma.post.update({ + where: { id: postId }, + data: updateData, + include: { + author: { + select: { + id: true, + name: true, + email: true, + createdAt: true + } + } + } + }); + + res.status(200).json(updatedPost); + } catch (error) { + console.error('Update post error:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to update post' + }); + } +}; \ No newline at end of file diff --git a/backend/src/controllers/users.ts b/backend/src/controllers/users.ts new file mode 100644 index 0000000..9bb5bc0 --- /dev/null +++ b/backend/src/controllers/users.ts @@ -0,0 +1,274 @@ +import { Request, Response } from 'express'; +import { PrismaClient } from '@prisma/client'; +import { hashPassword, comparePassword } from '../utils/auth'; + +const prisma = new PrismaClient(); + +interface AuthRequest extends Request { + user?: { + userId: number; + email: string; + }; +} + +interface UpdateUserRequest { + name?: string; + email?: string; + password?: string; + currentPassword?: string; +} + +/** + * Get current user information + * GET /users/me + */ +export const getCurrentUser = async (req: AuthRequest, res: Response): Promise => { + try { + if (!req.user) { + res.status(401).json({ + error: 'Authentication required', + message: 'You must be logged in to access this resource' + }); + return; + } + + const user = await prisma.user.findUnique({ + where: { id: req.user.userId }, + select: { + id: true, + email: true, + name: true, + createdAt: true, + updatedAt: true, + _count: { + select: { + posts: true + } + } + } + }); + + if (!user) { + res.status(404).json({ + error: 'User not found', + message: 'User account no longer exists' + }); + return; + } + + res.status(200).json(user); + } catch (error) { + console.error('Get current user error:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to fetch user information' + }); + } +}; + +/** + * Update current user information + * PUT /users/me + */ +export const updateCurrentUser = async (req: AuthRequest, res: Response): Promise => { + try { + if (!req.user) { + res.status(401).json({ + error: 'Authentication required', + message: 'You must be logged in to update your profile' + }); + return; + } + + const { name, email, password, currentPassword }: UpdateUserRequest = req.body; + + // Validate that at least one field is provided + if (!name && !email && !password) { + res.status(400).json({ + error: 'Validation error', + message: 'At least one field (name, email, or password) must be provided' + }); + return; + } + + // Get current user + const currentUser = await prisma.user.findUnique({ + where: { id: req.user.userId } + }); + + if (!currentUser) { + res.status(404).json({ + error: 'User not found', + message: 'User account no longer exists' + }); + return; + } + + // Prepare update data + const updateData: any = {}; + + // Update name + if (name !== undefined) { + if (name.trim().length < 2) { + res.status(400).json({ + error: 'Validation error', + message: 'Name must be at least 2 characters long' + }); + return; + } + updateData.name = name.trim(); + } + + // Update email + if (email !== undefined) { + if (!email.includes('@')) { + res.status(400).json({ + error: 'Validation error', + message: 'Please provide a valid email address' + }); + return; + } + + // Check if email is already taken by another user + const existingUser = await prisma.user.findUnique({ + where: { email } + }); + + if (existingUser && existingUser.id !== req.user.userId) { + res.status(409).json({ + error: 'Email already exists', + message: 'This email is already registered to another account' + }); + return; + } + + updateData.email = email.toLowerCase().trim(); + } + + // Update password + if (password !== undefined) { + if (!currentPassword) { + res.status(400).json({ + error: 'Validation error', + message: 'Current password is required to set a new password' + }); + return; + } + + // Verify current password + const isValidCurrentPassword = await comparePassword(currentPassword, currentUser.password); + + if (!isValidCurrentPassword) { + res.status(400).json({ + error: 'Authentication failed', + message: 'Current password is incorrect' + }); + return; + } + + if (password.length < 6) { + res.status(400).json({ + error: 'Validation error', + message: 'New password must be at least 6 characters long' + }); + return; + } + + updateData.password = await hashPassword(password); + } + + // Update user + const updatedUser = await prisma.user.update({ + where: { id: req.user.userId }, + data: updateData, + select: { + id: true, + email: true, + name: true, + createdAt: true, + updatedAt: true, + _count: { + select: { + posts: true + } + } + } + }); + + res.status(200).json({ + message: 'Profile updated successfully', + user: updatedUser + }); + } catch (error) { + console.error('Update user error:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to update user information' + }); + } +}; + +/** + * Delete current user account + * DELETE /users/me + */ +export const deleteCurrentUser = async (req: AuthRequest, res: Response): Promise => { + try { + if (!req.user) { + res.status(401).json({ + error: 'Authentication required', + message: 'You must be logged in to delete your account' + }); + return; + } + + const { password } = req.body; + + if (!password) { + res.status(400).json({ + error: 'Validation error', + message: 'Password confirmation is required to delete your account' + }); + return; + } + + // Get current user + const currentUser = await prisma.user.findUnique({ + where: { id: req.user.userId } + }); + + if (!currentUser) { + res.status(404).json({ + error: 'User not found', + message: 'User account no longer exists' + }); + return; + } + + // Verify password + const isValidPassword = await comparePassword(password, currentUser.password); + + if (!isValidPassword) { + res.status(400).json({ + error: 'Authentication failed', + message: 'Password is incorrect' + }); + return; + } + + // Delete user (this will cascade delete all posts due to schema setup) + await prisma.user.delete({ + where: { id: req.user.userId } + }); + + res.status(200).json({ + message: 'Account deleted successfully' + }); + } catch (error) { + console.error('Delete user error:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to delete user account' + }); + } +}; \ No newline at end of file diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..3e9a733 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,65 @@ +import { Request, Response, NextFunction } from 'express'; +import { verifyToken, extractTokenFromHeader, JWTPayload } from '../utils/auth'; + +// Extend Request interface to include user +declare global { + namespace Express { + interface Request { + user?: JWTPayload; + } + } +} + +/** + * Middleware to verify JWT token and authenticate user + */ +export const authenticateToken = ( + req: Request, + res: Response, + next: NextFunction +): void => { + try { + const token = extractTokenFromHeader(req.headers.authorization); + + if (!token) { + res.status(401).json({ + error: 'Authentication required', + message: 'No token provided' + }); + return; + } + + const decoded = verifyToken(token); + req.user = decoded; + + next(); + } catch (error) { + res.status(401).json({ + error: 'Authentication failed', + message: error instanceof Error ? error.message : 'Invalid token' + }); + } +}; + +/** + * Optional auth middleware - doesn't fail if no token + */ +export const optionalAuth = ( + req: Request, + res: Response, + next: NextFunction +): void => { + try { + const token = extractTokenFromHeader(req.headers.authorization); + + if (token) { + const decoded = verifyToken(token); + req.user = decoded; + } + + next(); + } catch (error) { + // Ignore auth errors in optional auth + next(); + } +}; \ No newline at end of file diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..ee30e11 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,12 @@ +import { login, register } from "../controllers/auth"; +import { Router } from "express"; + +const router: Router = Router(); + +// POST /auth/register +router.post("/register", register); + +// POST /auth/login +router.post("/login", login); + +export default router; diff --git a/backend/src/routes/posts.ts b/backend/src/routes/posts.ts new file mode 100644 index 0000000..01254a7 --- /dev/null +++ b/backend/src/routes/posts.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { getAllPosts, getPostById, createPost, updatePost } from '../controllers/posts'; +import { authenticateToken } from '../middleware/auth'; + +const router: Router = Router(); + +// GET /posts - отримати всі пости +router.get('/', getAllPosts); + +// GET /posts/:id - отримати конкретний пост +router.get('/:id', getPostById); + +// POST /posts - створити новий пост (тільки авторизований користувач) +router.post('/', authenticateToken, createPost); + +// PUT /posts/:id - оновити пост (тільки автор поста) +router.put('/:id', authenticateToken, updatePost); + +export default router; \ No newline at end of file diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts new file mode 100644 index 0000000..869e5b1 --- /dev/null +++ b/backend/src/routes/users.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { getCurrentUser, updateCurrentUser, deleteCurrentUser } from '../controllers/users'; +import { authenticateToken } from '../middleware/auth'; + +const router: Router = Router(); + +// All user routes require authentication +router.use(authenticateToken); + +// GET /users/me - отримати інформацію про поточного авторизованого користувача +router.get('/me', getCurrentUser); + +// PUT /users/me - оновити інформацію (ім'я, email, пароль) +router.put('/me', updateCurrentUser); + +// DELETE /users/me - видалити свій акаунт (опціонально) +router.delete('/me', deleteCurrentUser); + +export default router; \ No newline at end of file diff --git a/backend/src/server.ts b/backend/src/server.ts index 2d22719..e8e997e 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,6 +1,6 @@ import app from './app'; -import { config } from '@/config/config'; -import { prisma } from '@/config/database'; +import { config } from './config/config'; +import { prisma } from './config/database'; const startServer = async () => { try { diff --git a/backend/src/types/auth.ts b/backend/src/types/auth.ts new file mode 100644 index 0000000..be819be --- /dev/null +++ b/backend/src/types/auth.ts @@ -0,0 +1,25 @@ +export interface RegisterRequest { + email: string; + password: string; +} + +export interface LoginRequest { + email: string; + password: string; +} + +export interface AuthResponse { + user: { + id: number; + email: string; + name: string | null; + createdAt: Date; + }; + token: string; +} + +export interface ApiError { + error: string; + message: string; + details?: string[]; +} \ No newline at end of file diff --git a/backend/src/utils/auth.ts b/backend/src/utils/auth.ts new file mode 100644 index 0000000..86cdce7 --- /dev/null +++ b/backend/src/utils/auth.ts @@ -0,0 +1,58 @@ +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcryptjs'; +import { config } from '../config/config'; + +export interface JWTPayload { + userId: number; + email: string; +} + +/** + * Generate JWT token for user + */ +export const generateToken = (payload: JWTPayload): string => { + return jwt.sign(payload, config.jwtSecret, { + expiresIn: '24h', + }); +}; + +/** + * Verify and decode JWT token + */ +export const verifyToken = (token: string): JWTPayload => { + try { + const decoded = jwt.verify(token, config.jwtSecret) as JWTPayload; + return decoded; + } catch (error) { + throw new Error('Invalid or expired token'); + } +}; + +/** + * Hash password using bcrypt + */ +export const hashPassword = async (password: string): Promise => { + const saltRounds = 12; + return await bcrypt.hash(password, saltRounds); +}; + +/** + * Compare password with hash + */ +export const comparePassword = async ( + password: string, + hash: string +): Promise => { + return await bcrypt.compare(password, hash); +}; + +/** + * Extract token from Authorization header + */ +export const extractTokenFromHeader = (authHeader?: string): string | null => { + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return null; + } + + return authHeader.substring(7); // Remove 'Bearer ' prefix +}; \ No newline at end of file diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..edcaef2 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/frontend/next.config.ts b/frontend/next.config.ts index e9ffa30..d641941 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,7 +1,21 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + // Ensure dynamic routes work properly on Vercel + trailingSlash: false, + + // Disable experimental features that might cause issues + experimental: {}, + + // Ensure TypeScript checking + typescript: { + ignoreBuildErrors: false, + }, + + // Standard configuration for Vercel + eslint: { + ignoreDuringBuilds: false, + }, }; export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json index 9240b8e..d17e0c5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,23 +4,37 @@ "private": true, "scripts": { "dev": "next dev --turbopack", - "build": "next build --turbopack", + "build": "next build", "start": "next start", "lint": "biome check", "format": "biome format --write" }, "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@tanstack/react-query": "^5.89.0", + "axios": "^1.12.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "lucide-react": "^0.544.0", + "next": "15.5.3", "react": "19.1.0", "react-dom": "19.1.0", - "next": "15.5.3" + "react-hook-form": "^7.62.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.3.1", + "zod": "^4.1.9" }, "devDependencies": { - "typescript": "^5", + "@biomejs/biome": "2.2.0", + "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "@tailwindcss/postcss": "^4", "tailwindcss": "^4", - "@biomejs/biome": "2.2.0" + "tw-animate-css": "^1.3.8", + "typescript": "^5" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 61a0eb2..d75bc36 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,6 +8,33 @@ importers: .: dependencies: + '@hookform/resolvers': + specifier: ^5.2.2 + version: 5.2.2(react-hook-form@7.62.0(react@19.1.0)) + '@radix-ui/react-label': + specifier: ^2.1.7 + version: 2.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': + specifier: ^1.2.3 + version: 1.2.3(@types/react@19.1.13)(react@19.1.0) + '@tanstack/react-query': + specifier: ^5.89.0 + version: 5.89.0(react@19.1.0) + axios: + specifier: ^1.12.2 + version: 1.12.2 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 + lucide-react: + specifier: ^0.544.0 + version: 0.544.0(react@19.1.0) next: specifier: 15.5.3 version: 15.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -17,6 +44,18 @@ importers: react-dom: specifier: 19.1.0 version: 19.1.0(react@19.1.0) + 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 + zod: + specifier: ^4.1.9 + version: 4.1.9 devDependencies: '@biomejs/biome': specifier: 2.2.0 @@ -36,6 +75,9 @@ importers: tailwindcss: specifier: ^4 version: 4.1.13 + tw-animate-css: + specifier: ^1.3.8 + version: 1.3.8 typescript: specifier: ^5 version: 5.9.2 @@ -102,6 +144,11 @@ packages: '@emnapi/runtime@1.5.0': resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} + '@hookform/resolvers@5.2.2': + resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} + peerDependencies: + react-hook-form: ^7.55.0 + '@img/sharp-darwin-arm64@0.34.3': resolution: {integrity: sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -295,6 +342,53 @@ packages: cpu: [x64] os: [win32] + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -386,6 +480,14 @@ packages: '@tailwindcss/postcss@4.1.13': resolution: {integrity: sha512-HLgx6YSFKJT7rJqh9oJs/TkBFhxuMOfUKSBEPYwV+t78POOBsdQ7crhZLzwcH3T0UyUuOzU/GK5pk5eKr3wCiQ==} + '@tanstack/query-core@5.89.0': + resolution: {integrity: sha512-joFV1MuPhSLsKfTzwjmPDrp8ENfZ9N23ymFu07nLfn3JCkSHy0CFgsyhHTJOmWaumC/WiNIKM0EJyduCF/Ih/Q==} + + '@tanstack/react-query@5.89.0': + resolution: {integrity: sha512-SXbtWSTSRXyBOe80mszPxpEbaN4XPRUp/i0EfQK1uyj3KCk/c8FuPJNIRwzOVe/OU3rzxrYtiNabsAmk1l714A==} + peerDependencies: + react: ^18 || ^19 + '@types/node@20.19.15': resolution: {integrity: sha512-W3bqcbLsRdFDVcmAM5l6oLlcl67vjevn8j1FPZ4nx+K5jNoWCh+FC/btxFoBPnvQlrHHDwfjp1kjIEDfwJ0Mog==} @@ -397,6 +499,16 @@ packages: '@types/react@19.1.13': resolution: {integrity: sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.12.2: + resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + caniuse-lite@1.0.30001741: resolution: {integrity: sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==} @@ -404,9 +516,16 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -421,20 +540,91 @@ packages: resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} engines: {node: '>=12.5.0'} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + detect-libc@2.1.0: resolution: {integrity: sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg==} engines: {node: '>=8'} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + enhanced-resolve@5.18.3: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + engines: {node: '>= 6'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + is-arrayish@0.3.4: resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} @@ -506,9 +696,26 @@ packages: resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} engines: {node: '>= 12.0.0'} + lucide-react@0.544.0: + resolution: {integrity: sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.19: resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} @@ -559,11 +766,20 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + react-dom@19.1.0: resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} peerDependencies: react: ^19.1.0 + react-hook-form@7.62.0: + resolution: {integrity: sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react@19.1.0: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} @@ -583,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'} @@ -600,6 +822,9 @@ packages: babel-plugin-macros: optional: true + tailwind-merge@3.3.1: + resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} + tailwindcss@4.1.13: resolution: {integrity: sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==} @@ -614,6 +839,9 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tw-animate-css@1.3.8: + resolution: {integrity: sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw==} + typescript@5.9.2: resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} @@ -626,6 +854,9 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + zod@4.1.9: + resolution: {integrity: sha512-HI32jTq0AUAC125z30E8bQNz0RQ+9Uc+4J7V97gLYjZVKRjeydPgGt6dvQzFrav7MYOUGFqqOGiHpA/fdbd0cQ==} + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -670,6 +901,11 @@ snapshots: tslib: 2.8.1 optional: true + '@hookform/resolvers@5.2.2(react-hook-form@7.62.0(react@19.1.0))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.62.0(react@19.1.0) + '@img/sharp-darwin-arm64@0.34.3': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.2.0 @@ -805,6 +1041,39 @@ snapshots: '@next/swc-win32-x64-msvc@15.5.3': optional: true + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.13)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.13 + + '@radix-ui/react-label@2.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.13)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + + '@radix-ui/react-slot@1.2.3(@types/react@19.1.13)(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.13 + + '@standard-schema/utils@0.3.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -881,6 +1150,13 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.13 + '@tanstack/query-core@5.89.0': {} + + '@tanstack/react-query@5.89.0(react@19.1.0)': + dependencies: + '@tanstack/query-core': 5.89.0 + react: 19.1.0 + '@types/node@20.19.15': dependencies: undici-types: 6.21.0 @@ -893,12 +1169,33 @@ snapshots: dependencies: csstype: 3.1.3 + asynckit@0.4.0: {} + + axios@1.12.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + caniuse-lite@1.0.30001741: {} chownr@3.0.0: {} + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + client-only@0.0.1: {} + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -919,17 +1216,88 @@ snapshots: color-string: 1.9.1 optional: true + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + csstype@3.1.3: {} + date-fns@4.1.0: {} + + delayed-stream@1.0.0: {} + detect-libc@2.1.0: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 tapable: 2.2.3 + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + follow-redirects@1.15.11: {} + + form-data@4.0.4: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + gopd@1.2.0: {} + graceful-fs@4.2.11: {} + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + is-arrayish@0.3.4: optional: true @@ -980,10 +1348,22 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.1 lightningcss-win32-x64-msvc: 1.30.1 + lucide-react@0.544.0(react@19.1.0): + dependencies: + react: 19.1.0 + magic-string@0.30.19: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + minipass@7.1.2: {} minizlib@3.0.2: @@ -1031,11 +1411,17 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + proxy-from-env@1.1.0: {} + react-dom@19.1.0(react@19.1.0): dependencies: react: 19.1.0 scheduler: 0.26.0 + react-hook-form@7.62.0(react@19.1.0): + dependencies: + react: 19.1.0 + react@19.1.0: {} scheduler@0.26.0: {} @@ -1078,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): @@ -1085,6 +1476,8 @@ snapshots: client-only: 0.0.1 react: 19.1.0 + tailwind-merge@3.3.1: {} + tailwindcss@4.1.13: {} tapable@2.2.3: {} @@ -1100,8 +1493,12 @@ snapshots: tslib@2.8.1: {} + tw-animate-css@1.3.8: {} + typescript@5.9.2: {} undici-types@6.21.0: {} yallist@5.0.0: {} + + zod@4.1.9: {} diff --git a/frontend/src/app/auth/login/page.tsx b/frontend/src/app/auth/login/page.tsx new file mode 100644 index 0000000..1464d5e --- /dev/null +++ b/frontend/src/app/auth/login/page.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +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 { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; + +import { useLogin } from '@/hooks/use-auth'; +import { useAuth } from '@/contexts/auth-context'; +import { useEffect } from 'react'; + +const loginSchema = z.object({ + email: z.string().email('Введіть правильний email'), + password: z.string().min(6, 'Пароль повинен містити мінімум 6 символів'), +}); + +type LoginFormValues = z.infer; + +export default function LoginPage() { + const router = useRouter(); + const { isAuthenticated, isLoading } = useAuth(); + const loginMutation = useLogin(); + const [error, setError] = useState(''); + + const form = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { + email: '', + password: '', + }, + }); + + // Redirect if already authenticated + useEffect(() => { + if (!isLoading && isAuthenticated) { + router.push('/'); + } + }, [isAuthenticated, isLoading, router]); + + const onSubmit = async (data: LoginFormValues) => { + try { + setError(''); + await loginMutation.mutateAsync(data); + } catch (err: any) { + const errorMessage = err.response?.data?.message || 'Помилка входу'; + setError(errorMessage); + } + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (isAuthenticated) { + return null; // Will redirect + } + + return ( +
+ + + Вхід + + Введіть свої дані для входу в аккаунт + + + +
+ + ( + + Email + + + + + + )} + /> + ( + + Пароль + + + + + + )} + /> + + {error && ( +
+ {error} +
+ )} + + + + +
+ +

+ Немає аккаунту?{' '} + + Зареєструватися + +

+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/auth/register/page.tsx b/frontend/src/app/auth/register/page.tsx new file mode 100644 index 0000000..274d74a --- /dev/null +++ b/frontend/src/app/auth/register/page.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +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 { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; + +import { useRegister } from '@/hooks/use-auth'; +import { useAuth } from '@/contexts/auth-context'; +import { useEffect } from 'react'; + +const registerSchema = z.object({ + email: z.string().email('Введіть правильний email'), + password: z.string().min(6, 'Пароль повинен містити мінімум 6 символів'), + confirmPassword: z.string().min(6, 'Підтвердження пароля обов\'язкове'), +}).refine((data) => data.password === data.confirmPassword, { + message: 'Паролі не співпадають', + path: ['confirmPassword'], +}); + +type RegisterFormValues = z.infer; + +export default function RegisterPage() { + const router = useRouter(); + const { isAuthenticated, isLoading } = useAuth(); + const registerMutation = useRegister(); + const [error, setError] = useState(''); + + const form = useForm({ + resolver: zodResolver(registerSchema), + defaultValues: { + email: '', + password: '', + confirmPassword: '', + }, + }); + + // Redirect if already authenticated + useEffect(() => { + if (!isLoading && isAuthenticated) { + router.push('/'); + } + }, [isAuthenticated, isLoading, router]); + + const onSubmit = async (data: RegisterFormValues) => { + try { + setError(''); + const registerData = { + email: data.email, + password: data.password, + }; + await registerMutation.mutateAsync(registerData); + } catch (err: any) { + const errorMessage = err.response?.data?.message || 'Помилка реєстрації'; + setError(errorMessage); + } + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (isAuthenticated) { + return null; // Will redirect + } + + return ( +
+ + + Реєстрація + + Створіть новий аккаунт для доступу до блогу + + + +
+ + ( + + Email + + + + + + )} + /> + ( + + Пароль + + + + + + )} + /> + ( + + Підтвердження пароля + + + + + + )} + /> + + {error && ( +
+ {error} +
+ )} + + + + +
+ +

+ Вже маєте аккаунт?{' '} + + Увійти + +

+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a2dc41e..dc98be7 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1,26 +1,122 @@ @import "tailwindcss"; +@import "tw-animate-css"; -:root { - --background: #ffffff; - --foreground: #171717; -} +@custom-variant dark (&:is(.dark *)); @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); } -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index f7fa87e..61d020d 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,6 +1,9 @@ import type { Metadata } from "next"; 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", @@ -13,8 +16,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Міні-блог", + description: "Простий блог з авторизацією", }; export default function RootLayout({ @@ -23,11 +26,16 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - {children} + + + {children} + + + ); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 0de028b..f1d85ee 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,7 +1,77 @@ +'use client'; + +import { useAuth } from '@/contexts/auth-context'; +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { useLogout } from '@/hooks/use-auth'; + export default function Home() { + const { isAuthenticated, isLoading, user } = useAuth(); + const router = useRouter(); + const logout = useLogout(); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push('/auth/login'); + } else if (!isLoading && isAuthenticated) { + router.push('/posts'); + } + }, [isAuthenticated, isLoading, router]); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!isAuthenticated) { + return null; // Will redirect + } + return ( -
- kkkk +
+ {/* Header */} +
+
+
+

Міні-блог

+
+ + Привіт, {user?.name || user?.email}! + + +
+
+
+
+ + {/* Main Content */} +
+
+

+ Ласкаво просимо до міні-блогу! +

+

+ Тут буде список постів та навігація +

+
+ + + +
+
+
); } diff --git a/frontend/src/app/post/edit/page.tsx b/frontend/src/app/post/edit/page.tsx new file mode 100644 index 0000000..74ed64d --- /dev/null +++ b/frontend/src/app/post/edit/page.tsx @@ -0,0 +1,280 @@ +'use client'; + +import { useAuth } from '@/contexts/auth-context'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useState, Suspense } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { useLogout } from '@/hooks/use-auth'; +import { usePost } from '@/hooks/use-posts'; +import { api } from '@/lib/api'; +import { ArrowLeft } from 'lucide-react'; +import { toast } from 'sonner'; + +function EditPostContent() { + const { isAuthenticated, isLoading, user } = useAuth(); + const router = useRouter(); + const searchParams = useSearchParams(); + const logout = useLogout(); + + const postIdParam = searchParams.get('post_id'); + const postId = postIdParam ? parseInt(postIdParam, 10) : null; + + const { data: post, isLoading: postLoading, error: postError } = usePost(postId || 0); + + const [formData, setFormData] = useState({ + title: '', + description: '', + content: '' + }); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push('/auth/login'); + } + }, [isAuthenticated, isLoading, router]); + + useEffect(() => { + if (!postIdParam) { + router.push('/posts'); + } + }, [postIdParam, router]); + + useEffect(() => { + if (post) { + // Перевірка, чи користувач є автором поста + if (user?.id !== post.author.id) { + toast.error('Ви можете редагувати тільки свої пости'); + router.push(`/post?post_id=${post.id}`); + return; + } + + setFormData({ + title: post.title, + description: post.description || '', + content: post.content + }); + } + }, [post, user, router]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!postId) return; + + setIsSubmitting(true); + try { + await api.put(`/posts/${postId}`, formData); + toast.success('Пост успішно оновлено!'); + router.push(`/post?post_id=${postId}`); + } catch (error: any) { + toast.error(error.response?.data?.message || 'Помилка при оновленні поста'); + } finally { + setIsSubmitting(false); + } + }; + + if (isLoading || postLoading) { + return ( +
+
+
+ ); + } + + if (!isAuthenticated || !postIdParam || !postId) { + return null; // Will redirect + } + + if (postError) { + return ( +
+
+
+
+
+ +
+
+ +
+
+
+
+ +
+ + +

+ Пост не знайдено +

+

+ {(postError as any)?.response?.status === 404 + ? 'Пост з таким ID не існує' + : `Помилка завантаження: ${(postError as any)?.response?.data?.message || 'Невідома помилка'}` + } +

+ +
+
+
+
+ ); + } + + if (!post) { + return null; + } + + return ( +
+ {/* Header */} +
+
+
+
+ + | + Редагування поста +
+
+ + + + {user?.name || user?.email} + + +
+
+
+
+ + {/* Main Content */} +
+ {/* Back Button */} +
+ +
+ + {/* Edit Form */} + + + Редагувати пост + + +
+
+ + setFormData(prev => ({ ...prev, title: e.target.value }))} + placeholder="Введіть заголовок поста..." + required + className="w-full" + /> +
+ +
+ + setFormData(prev => ({ ...prev, description: e.target.value }))} + placeholder="Введіть короткий опис поста (необов'язково)..." + className="w-full" + /> +
+ +
+ +