2026-02-19 23:20:19 +09:00
commit 0e21562088
139 changed files with 35467 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

49
.env.for-docker-compose Normal file
View File

@@ -0,0 +1,49 @@
# Backend (NestJS)
NODE_ENV=production
HOST=0.0.0.0
BACKEND_PORT=3001
APP_URL=
DB_HOST=
DB_PORT=
DB_USER=
DB_PASSWORD=
DB_NAME=
DB_SSL=
JWT_ACCESS_SECRET=change-me-access-secret-at-least-32-chars
JWT_REFRESH_SECRET=change-me-refresh-secret-at-least-32-chars
JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
MAGIC_LINK_TTL_MINUTES=20
PASSWORD_RESET_TTL_MINUTES=30
COOKIE_SECURE=false
COOKIE_DOMAIN=
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASS=
MAIL_FROM=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CALLBACK_URL=
FRONTEND_URL=
# Frontend (Next.js)
FRONTEND_PORT=3000
NEXT_PUBLIC_API_URL=http://localhost:3001
UPLOAD_R2_WORKER_API=
R2_UPLOAD_API_KEY=
# Frontend (Next.js)
FRONTEND_PORT=3000
NEXT_PUBLIC_API_URL=
UPLOAD_R2_WORKER_API=
R2_UPLOAD_API_KEY=

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.env

61
README.md Normal file
View File

@@ -0,0 +1,61 @@
# BackEnd
### init
```bash
# 1. Set up PostgreSQL and fill in .env
cp .env.example .env
# edit .env with your DB credentials
# 2. Run DB schema (or let TypeORM synchronize on first start)
psql -U postgres -d nestjs_blog -f database/init.sql
# 3. Seed admin users
npm run seed:admin
# 4. Seed blog posts
npm run seed:posts
```
### Compile and run
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
### Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
# FrontEnd
```bash
# development
$ npm run dev
# build,deploy
$ npm run build
```
# Deploy
```
docker compose down --volumes
docker compose build --no-cache
docker compose up --force-recreate
```

19
backend/.dockerignore Normal file
View File

@@ -0,0 +1,19 @@
# Versioning and metadata
.git
.gitignore
.dockerignore
# Build dependencies
dist
node_modules
coverage
# Environment (contains sensitive data)
.env
# Files not required for production
.editorconfig
Dockerfile
README.md
.eslintrc.js
nodemon.json

41
backend/.env.example Normal file
View File

@@ -0,0 +1,41 @@
NODE_ENV=development
HOST=0.0.0.0
PORT=5001
APP_URL=http://localhost:5001
# Database
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=password
DB_NAME=nestjs_blog
DB_SSL=false
# JWT
JWT_ACCESS_SECRET=change-me-access-secret-at-least-32-chars
JWT_REFRESH_SECRET=change-me-refresh-secret-at-least-32-chars
JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# Tokens
MAGIC_LINK_TTL_MINUTES=20
PASSWORD_RESET_TTL_MINUTES=30
# Cookies
COOKIE_SECURE=false
COOKIE_DOMAIN=
# Email (leave blank to use console fallback in dev)
MAIL_FROM=no-reply@blog.local
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
# Google OAuth
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CALLBACK_URL=http://localhost:5000/auth/google/callback
FRONTEND_URL=http://localhost:5000

100
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,100 @@
# -----------------------------
# Dependencies
# -----------------------------
node_modules/
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# -----------------------------
# Build Output
# -----------------------------
dist/
build/
coverage/
.nyc_output/
# TypeScript
*.tsbuildinfo
# -----------------------------
# Environment Variables
# -----------------------------
.env
.env.*
!.env.example
# -----------------------------
# Logs
# -----------------------------
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# -----------------------------
# OS Files
# -----------------------------
.DS_Store
Thumbs.db
# -----------------------------
# IDE / Editor
# -----------------------------
.vscode/
!.vscode/extensions.json
.idea/
*.swp
*.swo
# -----------------------------
# Testing
# -----------------------------
/jest-cache/
coverage/
# -----------------------------
# Misc
# -----------------------------
tmp/
temp/
.cache/
# -----------------------------
# Docker (optional)
# -----------------------------
docker-compose.override.yml
# -----------------------------
# Package Managers
# -----------------------------
.pnp/
.pnp.js
# -----------------------------
# Nest CLI
# -----------------------------
nest-cli.json.lock
# -----------------------------
# Firebase / Serverless (optional)
# -----------------------------
.firebase/
.serverless/
# -----------------------------
# Mac
# -----------------------------
.AppleDouble
.LSOverride
*.sqlite
*.sqlite3

4
backend/.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

14
backend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=base /app/dist ./dist
EXPOSE 3001
CMD ["node", "dist/src/main.js"]

94
backend/database/init.sql Normal file
View File

@@ -0,0 +1,94 @@
-- USERS
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY,
email VARCHAR(255) NOT NULL,
name VARCHAR(120),
role VARCHAR(20) NOT NULL DEFAULT 'MEMBER'
CHECK (role IN ('ADMIN', 'MANAGER', 'MEMBER')),
password_hash VARCHAR(255),
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_users_email UNIQUE (email)
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
-- REFRESH TOKENS
CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
replaced_by_token_hash VARCHAR(255),
user_agent TEXT,
ip VARCHAR(64),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_refresh_token_hash UNIQUE (token_hash)
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_expires ON refresh_tokens(user_id, expires_at);
-- MAGIC LINK TOKENS
CREATE TABLE IF NOT EXISTS magic_link_tokens (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
email VARCHAR(255) NOT NULL,
token_hash VARCHAR(255) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_magic_link_token_hash UNIQUE (token_hash)
);
CREATE INDEX IF NOT EXISTS idx_magic_link_email ON magic_link_tokens(email);
CREATE INDEX IF NOT EXISTS idx_magic_link_expires ON magic_link_tokens(expires_at);
-- PASSWORD RESET TOKENS
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_password_reset_token_hash UNIQUE (token_hash)
);
-- OAUTH ACCOUNTS
CREATE TABLE IF NOT EXISTS oauth_accounts (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL,
provider_id VARCHAR(255) NOT NULL,
email VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_oauth_provider_provider_id UNIQUE (provider, provider_id)
);
-- BLOG POSTS
CREATE TABLE IF NOT EXISTS blog_posts (
id UUID PRIMARY KEY,
title VARCHAR(255) NOT NULL,
slug VARCHAR(300),
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'published', 'archived')),
excerpt TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL,
content_format VARCHAR(20) NOT NULL DEFAULT 'markdown'
CHECK (content_format IN ('markdown', 'html')),
author_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
featured_image_url VARCHAR(500),
featured_image_alt VARCHAR(255),
is_featured BOOLEAN NOT NULL DEFAULT false,
views INTEGER NOT NULL DEFAULT 0,
tags TEXT NOT NULL DEFAULT '',
categories TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_blog_posts_slug UNIQUE (slug)
);
CREATE INDEX IF NOT EXISTS idx_blog_posts_status_created ON blog_posts(status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_blog_posts_featured ON blog_posts(is_featured, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_blog_posts_views ON blog_posts(views DESC);
CREATE INDEX IF NOT EXISTS idx_blog_posts_author ON blog_posts(author_id);

35
backend/eslint.config.mjs Normal file
View File

@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

8
backend/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

11274
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

103
backend/package.json Normal file
View File

@@ -0,0 +1,103 @@
{
"name": "FullStack-Blog-Nestjs-HTMX",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"seed:admin": "ts-node -P tsconfig.json scripts/seed-admin.ts",
"seed:posts": "ts-node -P tsconfig.json scripts/seed-blog-posts.ts",
"db:init": "psql -U $DB_USER -d $DB_NAME -f database/init.sql"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"cookie-parser": "^1.4.7",
"dotenv": "^17.3.1",
"marked": "^17.0.3",
"nodemailer": "^8.0.1",
"nunjucks": "^3.2.4",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"pg": "^8.18.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.28",
"uuid": "^13.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/marked": "^5.0.2",
"@types/node": "^22.10.7",
"@types/nodemailer": "^7.0.10",
"@types/nunjucks": "^3.2.6",
"@types/passport": "^1.0.17",
"@types/passport-google-oauth20": "^2.0.17",
"@types/supertest": "^6.0.2",
"@types/uuid": "^10.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"transformIgnorePatterns": [
"/node_modules/(?!(uuid)/)"
],
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@@ -0,0 +1,55 @@
/**
* Run: npx ts-node -P tsconfig.json scripts/seed-admin.ts
*/
import 'dotenv/config';
import { DataSource } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { v4 as uuidv4 } from 'uuid';
import { User, UserRole } from '../src/users/entities/user.entity';
const dataSource = new DataSource({
type: 'postgres',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
username: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'password',
database: process.env.DB_NAME || 'nestjs_blog',
entities: [User],
synchronize: true,
});
async function seed() {
await dataSource.initialize();
console.log('✅ Database connected');
const users = [
{ email: 'admin@gmail.com', role: UserRole.ADMIN, name: 'Admin' },
{ email: 'manager@gmail.com', role: UserRole.MANAGER, name: 'Manager' },
];
const repo = dataSource.getRepository(User);
const passwordHash = await bcrypt.hash('Whatever123$', 12);
for (const u of users) {
let user = await repo.findOne({ where: { email: u.email } });
if (!user) {
user = repo.create({ id: uuidv4(), ...u, passwordHash, isActive: true });
await repo.save(user);
console.log(`✅ Created ${u.role}: ${u.email}`);
} else {
user.role = u.role;
user.passwordHash = passwordHash;
user.isActive = true;
await repo.save(user);
console.log(`♻️ Updated ${u.role}: ${u.email}`);
}
}
await dataSource.destroy();
console.log('🎉 Seeding complete');
}
seed().catch((err) => {
console.error('❌ Seed failed:', err);
process.exit(1);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

83
backend/src/app.module.ts Normal file
View File

@@ -0,0 +1,83 @@
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { APP_FILTER, APP_GUARD } from '@nestjs/core';
import { validate } from './config/env.validation';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { RolesGuard } from './common/guards/roles.guard';
import { CsrfMiddleware } from './common/middleware/csrf.middleware';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { BlogPostsModule } from './blog-posts/blog-posts.module';
import { TokensModule } from './tokens/tokens.module';
import { User } from './users/entities/user.entity';
import { BlogPost } from './blog-posts/entities/blog-post.entity';
import { RefreshToken } from './tokens/entities/refresh-token.entity';
import { MagicLinkToken } from './tokens/entities/magic-link-token.entity';
import { PasswordResetToken } from './tokens/entities/password-reset-token.entity';
import { OAuthAccount } from './tokens/entities/oauth-account.entity';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
validate,
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
type: 'postgres',
host: configService.get<string>('DB_HOST'),
port: configService.get<number>('DB_PORT'),
username: configService.get<string>('DB_USER'),
password: configService.get<string>('DB_PASSWORD'),
database: configService.get<string>('DB_NAME'),
ssl:
configService.get<string>('DB_SSL') === 'true'
? { rejectUnauthorized: false }
: false,
entities: [
User,
BlogPost,
RefreshToken,
MagicLinkToken,
PasswordResetToken,
OAuthAccount,
],
synchronize: configService.get<string>('NODE_ENV') !== 'production',
logging: configService.get<string>('NODE_ENV') === 'development',
}),
inject: [ConfigService],
}),
AuthModule,
UsersModule,
BlogPostsModule,
TokensModule,
],
providers: [
{
provide: APP_FILTER,
useClass: AllExceptionsFilter,
},
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
{
provide: APP_GUARD,
useClass: RolesGuard,
},
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(CsrfMiddleware)
.forRoutes({ path: '*', method: RequestMethod.ALL });
}
}

View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View File

@@ -0,0 +1,110 @@
import {
Body,
Controller,
Get,
Post,
Query,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Request, Response } from 'express';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { MagicLinkRequestDto } from './dto/magic-link-request.dto';
import { PasswordResetConfirmDto } from './dto/password-reset-confirm.dto';
import { PasswordResetRequestDto } from './dto/password-reset-request.dto';
import { RegisterDto } from './dto/register.dto';
import { Public } from '../common/decorators/public.decorator';
import { CurrentUser } from '../common/decorators/current-user.decorator';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
/** Returns the JWT payload of the currently logged-in user.
* Used by the frontend to restore auth state (e.g. after Google OAuth redirect). */
@Get('me')
me(@CurrentUser() user: any) {
return { success: true, user };
}
@Post('register')
@Public()
register(@Body() dto: RegisterDto, @Req() req: Request, @Res() res: Response) {
return this.authService.register(dto, req, res);
}
@Post('login')
@Public()
login(@Body() dto: LoginDto, @Req() req: Request, @Res() res: Response) {
return this.authService.login(dto, req, res);
}
@Post('refresh')
@Public()
refresh(@Req() req: Request, @Res() res: Response) {
return this.authService.refresh(req, res);
}
@Post('logout')
@Public()
logout(@Req() req: Request, @Res() res: Response) {
return this.authService.logout(req, res);
}
@Post('magic-link')
@Public()
requestMagicLink(
@Body() dto: MagicLinkRequestDto,
@Req() req: Request,
@Res() res: Response,
) {
return this.authService.requestMagicLink(dto.email, req, res);
}
@Get('magic-link/verify')
@Public()
verifyMagicLink(
@Query('token') token: string,
@Req() req: Request,
@Res() res: Response,
) {
return this.authService.verifyMagicLink(token, req, res);
}
@Post('password-reset/request')
@Public()
requestPasswordReset(
@Body() dto: PasswordResetRequestDto,
@Req() req: Request,
@Res() res: Response,
) {
return this.authService.requestPasswordReset(dto.email, req, res);
}
@Post('password-reset/confirm')
@Public()
confirmPasswordReset(
@Body() dto: PasswordResetConfirmDto,
@Req() req: Request,
@Res() res: Response,
) {
return this.authService.confirmPasswordReset(dto, req, res);
}
@Get('google')
@Public()
@UseGuards(AuthGuard('google'))
googleAuth() {
// Passport redirects to Google
}
@Get('google/callback')
@Public()
@UseGuards(AuthGuard('google'))
googleCallback(@Req() req: Request, @Res() res: Response) {
return this.authService.handleGoogleCallback((req as any).user, req, res);
}
}

View File

@@ -0,0 +1,38 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../users/entities/user.entity';
import { RefreshToken } from '../tokens/entities/refresh-token.entity';
import { MagicLinkToken } from '../tokens/entities/magic-link-token.entity';
import { PasswordResetToken } from '../tokens/entities/password-reset-token.entity';
import { OAuthAccount } from '../tokens/entities/oauth-account.entity';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { GoogleStrategy } from './strategies/google.strategy';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_ACCESS_SECRET'),
signOptions: { expiresIn: (configService.get<string>('JWT_ACCESS_EXPIRES_IN') || '15m') as any },
}),
inject: [ConfigService],
}),
TypeOrmModule.forFeature([
User,
RefreshToken,
MagicLinkToken,
PasswordResetToken,
OAuthAccount,
]),
],
controllers: [AuthController],
providers: [AuthService, GoogleStrategy],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,368 @@
import {
BadRequestException,
ConflictException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { v4 as uuidv4 } from 'uuid';
import { User, UserRole } from '../users/entities/user.entity';
import { RefreshToken } from '../tokens/entities/refresh-token.entity';
import { MagicLinkToken } from '../tokens/entities/magic-link-token.entity';
import { PasswordResetToken } from '../tokens/entities/password-reset-token.entity';
import { OAuthAccount } from '../tokens/entities/oauth-account.entity';
import {
generateRawToken,
hashToken,
signAccessToken,
signRefreshToken,
verifyRefreshToken,
} from '../common/helpers/jwt.helper';
import { sendMail } from '../common/helpers/mailer.helper';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { PasswordResetConfirmDto } from './dto/password-reset-confirm.dto';
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private readonly userRepo: Repository<User>,
@InjectRepository(RefreshToken)
private readonly refreshTokenRepo: Repository<RefreshToken>,
@InjectRepository(MagicLinkToken)
private readonly magicLinkRepo: Repository<MagicLinkToken>,
@InjectRepository(PasswordResetToken)
private readonly pwdResetRepo: Repository<PasswordResetToken>,
@InjectRepository(OAuthAccount)
private readonly oauthRepo: Repository<OAuthAccount>,
private readonly configService: ConfigService,
) {}
// ─── Token helpers ───────────────────────────────────────────────────────────
private issueTokens(user: User) {
const accessSecret = this.configService.get<string>('JWT_ACCESS_SECRET');
const refreshSecret = this.configService.get<string>('JWT_REFRESH_SECRET');
const accessExpiresIn = this.configService.get<string>('JWT_ACCESS_EXPIRES_IN') || '15m';
const refreshExpiresIn = this.configService.get<string>('JWT_REFRESH_EXPIRES_IN') || '7d';
const accessToken = signAccessToken(
{ sub: user.id, email: user.email, role: user.role },
accessSecret,
accessExpiresIn,
);
const rawRefresh = generateRawToken();
const refreshToken = signRefreshToken({ sub: user.id }, refreshSecret, refreshExpiresIn);
return { accessToken, rawRefresh, refreshToken };
}
private cookieOptions(res: any, accessToken: string, refreshToken: string) {
const secure = this.configService.get<string>('COOKIE_SECURE') === 'true';
const domain = this.configService.get<string>('COOKIE_DOMAIN') || undefined;
// SameSite=none required for cross-subdomain requests (frontend ↔ backend on different subdomains)
// SameSite=none requires Secure=true
const sameSite = secure ? ('none' as const) : ('lax' as const);
const opts = { httpOnly: true, sameSite, secure, domain };
res.cookie('accessToken', accessToken, { ...opts, maxAge: 15 * 60 * 1000 });
res.cookie('refreshToken', refreshToken, { ...opts, maxAge: 7 * 24 * 60 * 60 * 1000 });
}
private clearCookies(res: any) {
const secure = this.configService.get<string>('COOKIE_SECURE') === 'true';
const domain = this.configService.get<string>('COOKIE_DOMAIN') || undefined;
const sameSite = secure ? ('none' as const) : ('lax' as const);
const opts = { httpOnly: true, sameSite, secure, domain };
res.clearCookie('accessToken', opts);
res.clearCookie('refreshToken', opts);
}
private safeUser(user: User) {
const { passwordHash, ...safe } = user as any;
return safe;
}
private get frontendUrl(): string {
return this.configService.get<string>('FRONTEND_URL') || 'http://localhost:3000';
}
// ─── Register ────────────────────────────────────────────────────────────────
async register(dto: RegisterDto, req: any, res: any) {
const existing = await this.userRepo.findOne({ where: { email: dto.email } });
if (existing) throw new ConflictException('Email already in use');
const passwordHash = await bcrypt.hash(dto.password, 12);
const user = this.userRepo.create({
id: uuidv4(),
email: dto.email,
name: dto.name,
role: UserRole.MEMBER,
passwordHash,
});
await this.userRepo.save(user);
const { accessToken, rawRefresh, refreshToken } = this.issueTokens(user);
await this.storeRefreshToken(user.id, rawRefresh, req);
this.cookieOptions(res, accessToken, refreshToken);
return res.json({ success: true, accessToken, refreshToken, user: this.safeUser(user) });
}
// ─── Login ────────────────────────────────────────────────────────────────────
async login(dto: LoginDto, req: any, res: any) {
const user = await this.userRepo.findOne({ where: { email: dto.email } });
if (!user || !user.passwordHash) {
throw new UnauthorizedException('Invalid email or password');
}
const valid = await bcrypt.compare(dto.password, user.passwordHash);
if (!valid) throw new UnauthorizedException('Invalid email or password');
if (!user.isActive) throw new UnauthorizedException('Account is deactivated');
const { accessToken, rawRefresh, refreshToken } = this.issueTokens(user);
await this.storeRefreshToken(user.id, rawRefresh, req);
this.cookieOptions(res, accessToken, refreshToken);
return res.json({ success: true, accessToken, refreshToken, user: this.safeUser(user) });
}
// ─── Refresh ──────────────────────────────────────────────────────────────────
async refresh(req: any, res: any) {
const rawToken = req.cookies?.refreshToken || req.body?.refreshToken;
if (!rawToken) throw new UnauthorizedException('No refresh token provided');
const refreshSecret = this.configService.get<string>('JWT_REFRESH_SECRET');
let payload: any;
try {
payload = verifyRefreshToken(rawToken, refreshSecret);
} catch {
throw new UnauthorizedException('Invalid or expired refresh token');
}
const tokenHash = hashToken(rawToken);
const stored = await this.refreshTokenRepo.findOne({
where: { tokenHash },
relations: ['user'],
});
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
throw new UnauthorizedException('Refresh token invalid or revoked');
}
const user = stored.user;
// Rotate: revoke old, issue new
const newRaw = generateRawToken();
const refreshExpiresIn = this.configService.get<string>('JWT_REFRESH_EXPIRES_IN') || '7d';
const newRefreshSecret = this.configService.get<string>('JWT_REFRESH_SECRET');
const newRefreshToken = signRefreshToken({ sub: user.id }, newRefreshSecret, refreshExpiresIn);
const newHash = hashToken(newRaw);
stored.revokedAt = new Date();
stored.replacedByTokenHash = newHash;
await this.refreshTokenRepo.save(stored);
await this.storeRefreshToken(user.id, newRaw, req);
const { accessToken } = this.issueTokens(user);
this.cookieOptions(res, accessToken, newRefreshToken);
return res.json({ success: true, accessToken, refreshToken: newRefreshToken });
}
// ─── Logout ───────────────────────────────────────────────────────────────────
async logout(req: any, res: any) {
const rawToken = req.cookies?.refreshToken || req.body?.refreshToken;
if (rawToken) {
const tokenHash = hashToken(rawToken);
await this.refreshTokenRepo.update({ tokenHash }, { revokedAt: new Date() });
}
this.clearCookies(res);
return res.json({ success: true });
}
// ─── Magic Link ───────────────────────────────────────────────────────────────
async requestMagicLink(email: string, req: any, res: any) {
const appUrl = this.configService.get<string>('APP_URL') || 'http://localhost:3001';
const ttl = this.configService.get<number>('MAGIC_LINK_TTL_MINUTES') || 20;
const user = await this.userRepo.findOne({ where: { email } });
const rawToken = generateRawToken();
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + ttl * 60 * 1000);
await this.magicLinkRepo.save(
this.magicLinkRepo.create({
id: uuidv4(),
userId: user?.id || null,
email,
tokenHash,
expiresAt,
}),
);
// Link points to backend verify endpoint which then redirects to frontend
const link = `${appUrl}/auth/magic-link/verify?token=${rawToken}`;
await sendMail({
to: email,
subject: 'Your Magic Link',
html: `<p>Click <a href="${link}">here</a> to sign in. Link expires in ${ttl} minutes.</p><p>${link}</p>`,
});
return res.json({ success: true, message: 'Magic link sent' });
}
async verifyMagicLink(rawToken: string, req: any, res: any) {
const tokenHash = hashToken(rawToken);
const record = await this.magicLinkRepo.findOne({ where: { tokenHash } });
if (!record || record.consumedAt || record.expiresAt < new Date()) {
throw new UnauthorizedException('Invalid or expired magic link');
}
record.consumedAt = new Date();
await this.magicLinkRepo.save(record);
let user = await this.userRepo.findOne({ where: { email: record.email } });
if (!user) {
user = this.userRepo.create({
id: uuidv4(),
email: record.email,
role: UserRole.MEMBER,
isActive: true,
});
await this.userRepo.save(user);
}
const { accessToken, rawRefresh, refreshToken } = this.issueTokens(user);
await this.storeRefreshToken(user.id, rawRefresh, req);
this.cookieOptions(res, accessToken, refreshToken);
// Redirect to Next.js frontend dashboard
return res.redirect(`${this.frontendUrl}/dashboard`);
}
// ─── Password Reset ───────────────────────────────────────────────────────────
async requestPasswordReset(email: string, req: any, res: any) {
const frontendUrl = this.frontendUrl;
const ttl = this.configService.get<number>('PASSWORD_RESET_TTL_MINUTES') || 30;
const user = await this.userRepo.findOne({ where: { email } });
if (!user) {
// Return success to avoid enumeration
return res.json({ success: true });
}
const rawToken = generateRawToken();
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + ttl * 60 * 1000);
await this.pwdResetRepo.save(
this.pwdResetRepo.create({ id: uuidv4(), userId: user.id, tokenHash, expiresAt }),
);
// Link points to Next.js frontend auth page with reset token
const link = `${frontendUrl}/auth?tab=reset&token=${rawToken}`;
await sendMail({
to: email,
subject: 'Password Reset',
html: `<p>Click <a href="${link}">here</a> to reset your password. Expires in ${ttl} minutes.</p>`,
});
return res.json({ success: true });
}
async confirmPasswordReset(dto: PasswordResetConfirmDto, req: any, res: any) {
const tokenHash = hashToken(dto.token);
const record = await this.pwdResetRepo.findOne({
where: { tokenHash },
relations: ['user'],
});
if (!record || record.consumedAt || record.expiresAt < new Date()) {
throw new BadRequestException('Invalid or expired reset token');
}
const passwordHash = await bcrypt.hash(dto.password, 12);
await this.userRepo.update(record.userId, { passwordHash });
record.consumedAt = new Date();
await this.pwdResetRepo.save(record);
return res.json({ success: true });
}
// ─── Google OAuth ──────────────────────────────────────────────────────────────
async handleGoogleCallback(googleUser: any, req: any, res: any) {
const { providerId, email, name } = googleUser;
let oauthAccount = await this.oauthRepo.findOne({
where: { provider: 'google', providerId },
relations: ['user'],
});
let user: User;
if (oauthAccount) {
user = oauthAccount.user;
} else {
user = await this.userRepo.findOne({ where: { email } });
if (!user) {
user = this.userRepo.create({
id: uuidv4(),
email,
name,
role: UserRole.MEMBER,
isActive: true,
});
await this.userRepo.save(user);
}
oauthAccount = this.oauthRepo.create({
id: uuidv4(),
userId: user.id,
provider: 'google',
providerId,
email,
});
await this.oauthRepo.save(oauthAccount);
}
const { accessToken, rawRefresh, refreshToken } = this.issueTokens(user);
await this.storeRefreshToken(user.id, rawRefresh, req);
this.cookieOptions(res, accessToken, refreshToken);
// Redirect to Next.js frontend dashboard after Google OAuth
return res.redirect(`${this.frontendUrl}/dashboard`);
}
// ─── Private ──────────────────────────────────────────────────────────────────
private async storeRefreshToken(userId: string, rawToken: string, req: any) {
const tokenHash = hashToken(rawToken);
const refreshExpiresIn = this.configService.get<string>('JWT_REFRESH_EXPIRES_IN') || '7d';
const days = parseInt(refreshExpiresIn.replace('d', ''), 10) || 7;
const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
await this.refreshTokenRepo.save(
this.refreshTokenRepo.create({
id: uuidv4(),
userId,
tokenHash,
expiresAt,
userAgent: req.headers?.['user-agent'] || null,
ip: req.ip || null,
}),
);
}
}

View File

@@ -0,0 +1,10 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsEmail()
email: string;
@IsString()
@MinLength(1)
password: string;
}

View File

@@ -0,0 +1,6 @@
import { IsEmail } from 'class-validator';
export class MagicLinkRequestDto {
@IsEmail()
email: string;
}

View File

@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class MagicLinkVerifyDto {
@IsString()
token: string;
}

View File

@@ -0,0 +1,10 @@
import { IsString, MinLength } from 'class-validator';
export class PasswordResetConfirmDto {
@IsString()
token: string;
@IsString()
@MinLength(8)
password: string;
}

View File

@@ -0,0 +1,6 @@
import { IsEmail } from 'class-validator';
export class PasswordResetRequestDto {
@IsEmail()
email: string;
}

View File

@@ -0,0 +1,15 @@
import { IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class RegisterDto {
@IsEmail()
email: string;
@IsString()
@MinLength(8)
password: string;
@IsString()
@MaxLength(120)
@IsOptional()
name?: string;
}

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(configService: ConfigService) {
super({
clientID: configService.get<string>('GOOGLE_CLIENT_ID') || 'dummy',
clientSecret: configService.get<string>('GOOGLE_CLIENT_SECRET') || 'dummy',
callbackURL: configService.get<string>('GOOGLE_CALLBACK_URL'),
scope: ['email', 'profile'],
});
}
async validate(
accessToken: string,
refreshToken: string,
profile: any,
done: VerifyCallback,
): Promise<any> {
const { id, displayName, emails } = profile;
const user = {
providerId: id,
provider: 'google',
email: emails?.[0]?.value,
name: displayName,
};
done(null, user);
}
}

View File

@@ -0,0 +1,83 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { BlogPostsService } from './blog-posts.service';
import { CreatePostDto } from './dto/create-post.dto';
import { UpdatePostDto } from './dto/update-post.dto';
import { ListPostsQueryDto } from './dto/list-posts-query.dto';
import { Public } from '../common/decorators/public.decorator';
import { Roles } from '../common/decorators/roles.decorator';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { UserRole } from '../users/entities/user.entity';
@Controller('blog-posts')
export class BlogPostsController {
constructor(private readonly blogPostsService: BlogPostsService) {}
// ─── Public endpoints ────────────────────────────────────────────────────────
@Get('public')
@Public()
async findPublished(@Query() query: ListPostsQueryDto) {
return this.blogPostsService.findPublished(query);
}
@Get('public/featured')
@Public()
async findFeatured() {
return this.blogPostsService.findFeatured();
}
@Get('public/:slug')
@Public()
async findBySlug(@Param('slug') slug: string) {
const post = await this.blogPostsService.findBySlug(slug);
return { success: true, post };
}
@Post('public/:slug/view')
@Public()
async incrementViews(@Param('slug') slug: string) {
await this.blogPostsService.incrementViews(slug);
return { success: true };
}
// ─── Authenticated CRUD ───────────────────────────────────────────────────────
@Get()
async findAll(@Query() query: ListPostsQueryDto, @CurrentUser() user: any) {
return this.blogPostsService.findAll(query, user);
}
@Post()
@Roles(UserRole.ADMIN, UserRole.MANAGER)
async create(@Body() dto: CreatePostDto, @CurrentUser() user: any) {
const post = await this.blogPostsService.create(dto, user);
return { success: true, post };
}
@Patch(':id')
@Roles(UserRole.ADMIN, UserRole.MANAGER)
async update(
@Param('id') id: string,
@Body() dto: UpdatePostDto,
@CurrentUser() user: any,
) {
const post = await this.blogPostsService.update(id, dto, user);
return { success: true, post };
}
@Delete(':id')
@Roles(UserRole.ADMIN)
async remove(@Param('id') id: string) {
await this.blogPostsService.remove(id);
return { success: true };
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BlogPost } from './entities/blog-post.entity';
import { User } from '../users/entities/user.entity';
import { BlogPostsController } from './blog-posts.controller';
import { BlogPostsService } from './blog-posts.service';
@Module({
imports: [TypeOrmModule.forFeature([BlogPost, User])],
controllers: [BlogPostsController],
providers: [BlogPostsService],
exports: [BlogPostsService],
})
export class BlogPostsModule {}

View File

@@ -0,0 +1,150 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { NotFoundException } from '@nestjs/common';
import { BlogPostsService } from './blog-posts.service';
import { BlogPost, PostStatus, ContentFormat } from './entities/blog-post.entity';
import { UserRole } from '../users/entities/user.entity';
const mockPost: Partial<BlogPost> = {
id: 'post-uuid-1',
title: 'Test Post',
slug: 'test-post-abc',
status: PostStatus.PUBLISHED,
excerpt: 'A test post',
content: '# Test',
contentFormat: ContentFormat.MARKDOWN,
authorId: 'user-1',
isFeatured: false,
views: 100,
tags: ['test'],
categories: ['Testing'],
};
const mockQueryBuilder = {
leftJoin: jest.fn().mockReturnThis(),
leftJoinAndSelect: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
addOrderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getCount: jest.fn().mockResolvedValue(1),
getMany: jest.fn().mockResolvedValue([mockPost]),
getOne: jest.fn().mockResolvedValue(mockPost),
};
const mockRepo = {
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
findOne: jest.fn(),
find: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
increment: jest.fn(),
remove: jest.fn(),
};
describe('BlogPostsService', () => {
let service: BlogPostsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BlogPostsService,
{ provide: getRepositoryToken(BlogPost), useValue: mockRepo },
],
}).compile();
service = module.get<BlogPostsService>(BlogPostsService);
jest.clearAllMocks();
mockRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder);
});
describe('findPublished', () => {
it('should return paginated published posts', async () => {
const result = await service.findPublished({});
expect(result.items).toHaveLength(1);
expect(result.total).toBe(1);
expect(mockQueryBuilder.where).toHaveBeenCalledWith(
'post.status = :status',
{ status: PostStatus.PUBLISHED },
);
});
});
describe('findBySlug', () => {
it('should return published post by slug', async () => {
mockQueryBuilder.getOne.mockResolvedValue(mockPost);
const post = await service.findBySlug('test-post-abc');
expect(post.slug).toBe('test-post-abc');
});
it('should throw NotFoundException for draft post', async () => {
mockQueryBuilder.getOne.mockResolvedValue({ ...mockPost, status: PostStatus.DRAFT });
await expect(service.findBySlug('test-post-abc')).rejects.toThrow(NotFoundException);
});
it('should throw NotFoundException for non-existent slug', async () => {
mockQueryBuilder.getOne.mockResolvedValue(null);
await expect(service.findBySlug('nonexistent')).rejects.toThrow(NotFoundException);
});
});
describe('create', () => {
const adminUser = { sub: 'user-1', role: UserRole.ADMIN };
const managerUser = { sub: 'user-2', role: UserRole.MANAGER };
it('ADMIN can create published post', async () => {
mockRepo.create.mockReturnValue({ ...mockPost });
mockRepo.save.mockResolvedValue({ ...mockPost, status: PostStatus.PUBLISHED });
const result = await service.create(
{ title: 'New Post', content: 'Content', status: PostStatus.PUBLISHED },
adminUser,
);
expect(mockRepo.save).toHaveBeenCalled();
});
it('MANAGER is forced to draft', async () => {
mockRepo.create.mockImplementation((data) => ({ ...data }));
mockRepo.save.mockImplementation((data) => Promise.resolve(data));
const result = await service.create(
{ title: 'Manager Post', content: 'Content', status: PostStatus.PUBLISHED },
managerUser,
);
expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ status: PostStatus.DRAFT }),
);
});
});
describe('incrementViews', () => {
it('should increment post views', async () => {
mockRepo.increment.mockResolvedValue({});
await service.incrementViews('test-post-abc');
expect(mockRepo.increment).toHaveBeenCalledWith(
{ slug: 'test-post-abc' },
'views',
1,
);
});
});
describe('remove', () => {
it('should remove a post', async () => {
mockRepo.findOne.mockResolvedValue(mockPost);
mockRepo.remove.mockResolvedValue({});
await service.remove('post-uuid-1');
expect(mockRepo.remove).toHaveBeenCalledWith(mockPost);
});
it('should throw NotFoundException for non-existent post', async () => {
mockRepo.findOne.mockResolvedValue(null);
await expect(service.remove('nonexistent')).rejects.toThrow(NotFoundException);
});
});
});

View File

@@ -0,0 +1,284 @@
import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { v4 as uuidv4 } from 'uuid';
import { BlogPost, PostStatus } from './entities/blog-post.entity';
import { UserRole } from '../users/entities/user.entity';
import { CreatePostDto } from './dto/create-post.dto';
import { UpdatePostDto } from './dto/update-post.dto';
import { ListPostsQueryDto } from './dto/list-posts-query.dto';
import { generateSlug } from '../common/helpers/slug.helper';
// Safe author columns — never include passwordHash
const AUTHOR_COLS: string[] = [
'author.id',
'author.email',
'author.name',
'author.role',
'author.isActive',
'author.createdAt',
];
@Injectable()
export class BlogPostsService {
constructor(
@InjectRepository(BlogPost)
private readonly postRepo: Repository<BlogPost>,
) {}
// ─── Public (published) posts ──────────────────────────────────────────────
async findPublished(query: ListPostsQueryDto) {
const page = parseInt(query.page || '1', 10);
const pageSize = parseInt(query.pageSize || '9', 10);
const qb = this.postRepo
.createQueryBuilder('post')
.leftJoin('post.author', 'author')
.addSelect(AUTHOR_COLS)
.where('post.status = :status', { status: PostStatus.PUBLISHED });
if (query.q) {
qb.andWhere(
'(post.title ILIKE :q OR post.excerpt ILIKE :q)',
{ q: `%${query.q}%` },
);
}
// Support comma-separated tags (from HTMX filter form: name="tags")
if (query.tags) {
const tagList = query.tags.split(',').map((t) => t.trim()).filter(Boolean);
tagList.forEach((tag, i) => {
qb.andWhere(`post.tags LIKE :tag${i}`, { [`tag${i}`]: `%${tag}%` });
});
}
if (query.category) {
qb.andWhere('post.categories LIKE :cat', { cat: `%${query.category}%` });
}
// Sort options matching the home page template values
switch (query.sort) {
case 'oldest':
qb.orderBy('post.createdAt', 'ASC');
break;
case 'most_viewed':
qb.orderBy('post.views', 'DESC');
break;
case 'featured':
qb.orderBy('post.isFeatured', 'DESC').addOrderBy('post.createdAt', 'DESC');
break;
default: // 'newest' or undefined
qb.orderBy('post.createdAt', 'DESC');
}
const total = await qb.getCount();
const posts = await qb.skip((page - 1) * pageSize).take(pageSize).getMany();
return {
items: posts,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
async findFeatured(limit = 3) {
return this.postRepo
.createQueryBuilder('post')
.leftJoin('post.author', 'author')
.addSelect(AUTHOR_COLS)
.where('post.status = :status', { status: PostStatus.PUBLISHED })
.andWhere('post.isFeatured = true')
.orderBy('post.createdAt', 'DESC')
.take(limit)
.getMany();
}
async findPopular(limit = 5) {
return this.postRepo
.createQueryBuilder('post')
.where('post.status = :status', { status: PostStatus.PUBLISHED })
.orderBy('post.views', 'DESC')
.take(limit)
.select(['post.slug', 'post.title', 'post.views'])
.getMany();
}
async findRelated(post: BlogPost, limit = 4) {
const qb = this.postRepo
.createQueryBuilder('p')
.where('p.status = :status', { status: PostStatus.PUBLISHED })
.andWhere('p.id != :id', { id: post.id })
.orderBy('p.createdAt', 'DESC')
.take(limit);
// Prefer posts sharing at least one category or tag
const cats = (post.categories || []).filter(Boolean);
const tags = (post.tags || []).filter(Boolean);
if (cats.length > 0) {
qb.andWhere(
'(' + cats.map((_, i) => `p.categories LIKE :rc${i}`).join(' OR ') + ')',
Object.fromEntries(cats.map((c, i) => [`rc${i}`, `%${c}%`])),
);
} else if (tags.length > 0) {
qb.andWhere(
'(' + tags.map((_, i) => `p.tags LIKE :rt${i}`).join(' OR ') + ')',
Object.fromEntries(tags.map((t, i) => [`rt${i}`, `%${t}%`])),
);
}
const results = await qb.getMany();
// Fallback: if nothing related, return latest posts
if (results.length === 0) {
return this.postRepo
.createQueryBuilder('p')
.where('p.status = :status', { status: PostStatus.PUBLISHED })
.andWhere('p.id != :id', { id: post.id })
.orderBy('p.createdAt', 'DESC')
.take(limit)
.getMany();
}
return results;
}
async findBySlug(slug: string): Promise<BlogPost> {
const post = await this.postRepo
.createQueryBuilder('post')
.leftJoin('post.author', 'author')
.addSelect(AUTHOR_COLS)
.where('post.slug = :slug', { slug })
.getOne();
if (!post || post.status !== PostStatus.PUBLISHED) {
throw new NotFoundException('Post not found');
}
return post;
}
async incrementViews(slug: string) {
await this.postRepo.increment({ slug }, 'views', 1);
}
// ─── Admin / authenticated ──────────────────────────────────────────────────
async findAll(query: ListPostsQueryDto, user: any) {
const page = parseInt(query.page || '1', 10);
const pageSize = parseInt(query.pageSize || '20', 10);
const qb = this.postRepo
.createQueryBuilder('post')
.leftJoin('post.author', 'author')
.addSelect(AUTHOR_COLS);
if (user.role !== UserRole.ADMIN) {
qb.where('post.authorId = :uid', { uid: user.sub });
}
if (query.status) {
qb.andWhere('post.status = :status', { status: query.status });
}
if (query.q) {
qb.andWhere('post.title ILIKE :q', { q: `%${query.q}%` });
}
qb.orderBy('post.createdAt', 'DESC');
const total = await qb.getCount();
const posts = await qb.skip((page - 1) * pageSize).take(pageSize).getMany();
return { posts, total, page, pageSize, totalPages: Math.ceil(total / pageSize) };
}
async create(dto: CreatePostDto, user: any): Promise<BlogPost> {
let status = dto.status || PostStatus.DRAFT;
// MANAGER can only create drafts
if (user.role === UserRole.MANAGER) {
status = PostStatus.DRAFT;
}
// Use provided slug or generate from title
const slug = dto.slug ? dto.slug.trim() : generateSlug(dto.title);
const post = this.postRepo.create({
id: uuidv4(),
title: dto.title,
slug,
status,
excerpt: dto.excerpt || '',
content: dto.content,
contentFormat: dto.contentFormat,
authorId: user.sub,
featuredImageUrl: dto.featuredImageUrl,
featuredImageAlt: dto.featuredImageAlt,
isFeatured: dto.isFeatured || false,
tags: dto.tags || [],
categories: dto.categories || [],
});
return this.postRepo.save(post);
}
async update(id: string, dto: UpdatePostDto, user: any): Promise<BlogPost> {
const post = await this.postRepo.findOne({ where: { id } });
if (!post) throw new NotFoundException('Post not found');
// Regenerate slug if title changed and no explicit slug provided
if (dto.title && dto.title !== post.title && !dto.slug) {
(dto as any).slug = generateSlug(dto.title);
}
Object.assign(post, dto);
return this.postRepo.save(post);
}
async remove(id: string): Promise<void> {
const post = await this.postRepo.findOne({ where: { id } });
if (!post) throw new NotFoundException('Post not found');
await this.postRepo.remove(post);
}
// ─── Tag cloud ──────────────────────────────────────────────────────────────
async getTagCloud(): Promise<Array<{ name: string; count: number }>> {
const posts = await this.postRepo.find({
where: { status: PostStatus.PUBLISHED },
select: ['tags'],
});
const counts: Record<string, number> = {};
for (const post of posts) {
for (const tag of post.tags || []) {
const t = tag.trim();
if (t) counts[t] = (counts[t] || 0) + 1;
}
}
return Object.entries(counts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
}
// ─── Category cloud ─────────────────────────────────────────────────────────
async getCategoryCloud(): Promise<Array<{ name: string; count: number }>> {
const posts = await this.postRepo.find({
where: { status: PostStatus.PUBLISHED },
select: ['categories'],
});
const counts: Record<string, number> = {};
for (const post of posts) {
for (const cat of post.categories || []) {
const c = cat.trim();
if (c) counts[c] = (counts[c] || 0) + 1;
}
}
return Object.entries(counts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
}
}

View File

@@ -0,0 +1,78 @@
import { Transform } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { ContentFormat, PostStatus } from '../entities/blog-post.entity';
/** Parse "true"/"false" strings into a real boolean */
function transformBoolean({ value }: { value: unknown }): boolean | undefined {
if (value === 'true' || value === true) return true;
if (value === 'false' || value === false) return false;
return undefined;
}
/** Parse a comma-separated string OR an array into a trimmed string[] */
function transformCSV({ value }: { value: unknown }): string[] {
if (Array.isArray(value)) return (value as unknown[]).map((v) => String(v).trim()).filter(Boolean);
if (typeof value === 'string') return value.split(',').map((s) => s.trim()).filter(Boolean);
return [];
}
export class CreatePostDto {
@IsString()
@MinLength(1)
@MaxLength(255)
title: string;
/** Optional custom slug; if omitted the service generates one from the title */
@IsString()
@IsOptional()
@MaxLength(300)
slug?: string;
@IsString()
@IsOptional()
excerpt?: string;
@IsString()
@MinLength(1)
content: string;
@IsEnum(ContentFormat)
@IsOptional()
contentFormat?: ContentFormat;
@IsEnum(PostStatus)
@IsOptional()
status?: PostStatus;
@IsString()
@IsOptional()
featuredImageUrl?: string;
@IsString()
@IsOptional()
featuredImageAlt?: string;
@IsBoolean()
@IsOptional()
@Transform(transformBoolean)
isFeatured?: boolean;
/** Accepts a comma-separated string "tag1,tag2" or a plain string[] */
@IsString({ each: true })
@IsOptional()
@Transform(transformCSV)
tags?: string[];
/** Accepts a comma-separated string "cat1,cat2" or a plain string[] */
@IsString({ each: true })
@IsOptional()
@Transform(transformCSV)
categories?: string[];
}

View File

@@ -0,0 +1,37 @@
import { IsEnum, IsNumberString, IsOptional, IsString } from 'class-validator';
import { PostStatus } from '../entities/blog-post.entity';
export class ListPostsQueryDto {
@IsNumberString()
@IsOptional()
page?: string;
@IsNumberString()
@IsOptional()
pageSize?: string;
/** Full-text search on title + excerpt */
@IsString()
@IsOptional()
q?: string;
/** Comma-separated tags e.g. "nestjs,htmx" */
@IsString()
@IsOptional()
tags?: string;
/** Single category filter */
@IsString()
@IsOptional()
category?: string;
/** Sort order: newest | oldest | most_viewed | featured */
@IsEnum(['newest', 'oldest', 'most_viewed', 'featured'])
@IsOptional()
sort?: string;
/** Admin/manager status filter */
@IsEnum(PostStatus)
@IsOptional()
status?: PostStatus;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreatePostDto } from './create-post.dto';
export class UpdatePostDto extends PartialType(CreatePostDto) {}

View File

@@ -0,0 +1,82 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
export enum PostStatus {
DRAFT = 'draft',
PUBLISHED = 'published',
ARCHIVED = 'archived',
}
export enum ContentFormat {
MARKDOWN = 'markdown',
HTML = 'html',
}
@Entity('blog_posts')
export class BlogPost {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 255 })
title: string;
@Column({ length: 300, nullable: true, unique: true })
slug: string;
@Column({ type: 'varchar', length: 20, default: PostStatus.DRAFT })
status: PostStatus;
@Column({ type: 'text', default: '' })
excerpt: string;
@Column({ type: 'text' })
content: string;
@Column({ name: 'content_format', type: 'varchar', length: 20, default: ContentFormat.MARKDOWN })
contentFormat: ContentFormat;
@Column({ name: 'author_id' })
authorId: string;
@ManyToOne(() => User, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'author_id' })
author: User;
@Column({ name: 'featured_image_url', length: 500, nullable: true })
featuredImageUrl: string;
@Column({ name: 'featured_image_alt', length: 255, nullable: true })
featuredImageAlt: string;
@Column({ name: 'is_featured', default: false })
isFeatured: boolean;
@Column({ default: 0 })
views: number;
@Column({ type: 'simple-array', default: '' })
tags: string[];
@Column({ type: 'simple-array', default: '' })
categories: string[];
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
/** Virtual getter — templates use `post.featuredImage.url` and `post.featuredImage.alt` */
get featuredImage(): { url: string; alt: string } | null {
if (!this.featuredImageUrl) return null;
return { url: this.featuredImageUrl, alt: this.featuredImageAlt || '' };
}
}

View File

@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
},
);

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '../../users/entities/user.entity';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1,44 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response } from 'express';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const res = ctx.getResponse<Response>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
if (exception instanceof HttpException) {
status = exception.getStatus();
const responseBody = exception.getResponse();
if (typeof responseBody === 'string') {
message = responseBody;
} else if (typeof responseBody === 'object' && responseBody !== null) {
const body = responseBody as any;
message = body.message || body.error || message;
if (Array.isArray(message)) {
message = message[0];
}
}
} else if (exception instanceof Error) {
message = exception.message;
this.logger.error(exception.message, exception.stack);
}
res.status(status).json({
success: false,
error: { code: status, message },
});
}
}

View File

@@ -0,0 +1,54 @@
import {
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { verifyAccessToken } from '../helpers/jwt.helper';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtAuthGuard {
constructor(
private reflector: Reflector,
private configService: ConfigService,
) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest();
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException('Missing authentication token');
}
try {
const secret = this.configService.get<string>('JWT_ACCESS_SECRET');
const payload = verifyAccessToken(token, secret);
request.user = payload;
return true;
} catch {
throw new UnauthorizedException('Invalid or expired token');
}
}
private extractToken(request: any): string | null {
// Check Authorization header
const authHeader = request.headers?.authorization as string;
if (authHeader?.startsWith('Bearer ')) {
return authHeader.substring(7);
}
// Check cookie
if (request.cookies?.accessToken) {
return request.cookies.accessToken;
}
return null;
}
}

View File

@@ -0,0 +1,44 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { UserRole } from '../../users/entities/user.entity';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) return true;
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
throw new ForbiddenException('Access denied');
}
if (!requiredRoles.includes(user.role)) {
throw new ForbiddenException(
`Access denied. Required roles: ${requiredRoles.join(', ')}`,
);
}
return true;
}
}

View File

@@ -0,0 +1,46 @@
import * as crypto from 'crypto';
import * as jwt from 'jsonwebtoken';
export interface AccessTokenPayload {
sub: string;
email: string;
role: string;
type: 'access';
}
export interface RefreshTokenPayload {
sub: string;
type: 'refresh';
}
export function signAccessToken(
payload: Omit<AccessTokenPayload, 'type'>,
secret: string,
expiresIn: string,
): string {
return jwt.sign({ ...payload, type: 'access' }, secret, { expiresIn } as any);
}
export function signRefreshToken(
payload: Omit<RefreshTokenPayload, 'type'>,
secret: string,
expiresIn: string,
): string {
return jwt.sign({ ...payload, type: 'refresh' }, secret, { expiresIn } as any);
}
export function verifyAccessToken(token: string, secret: string): AccessTokenPayload {
return jwt.verify(token, secret) as AccessTokenPayload;
}
export function verifyRefreshToken(token: string, secret: string): RefreshTokenPayload {
return jwt.verify(token, secret) as RefreshTokenPayload;
}
export function hashToken(raw: string): string {
return crypto.createHash('sha256').update(raw).digest('hex');
}
export function generateRawToken(): string {
return crypto.randomBytes(48).toString('hex');
}

View File

@@ -0,0 +1,45 @@
import * as nodemailer from 'nodemailer';
export interface MailOptions {
to: string;
subject: string;
html: string;
}
let transporter: nodemailer.Transporter | null = null;
function getTransporter(): nodemailer.Transporter {
if (transporter) return transporter;
const host = process.env.SMTP_HOST;
if (!host) {
// Console fallback for development
transporter = nodemailer.createTransport({ jsonTransport: true });
return transporter;
}
transporter = nodemailer.createTransport({
host,
port: parseInt(process.env.SMTP_PORT || '587', 10),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
return transporter;
}
export async function sendMail(options: MailOptions): Promise<void> {
const from = process.env.MAIL_FROM || 'no-reply@blog.local';
const t = getTransporter();
if (!process.env.SMTP_HOST) {
console.log('[MAIL CONSOLE FALLBACK]');
console.log(` TO: ${options.to}`);
console.log(` SUBJECT: ${options.subject}`);
console.log(` BODY: ${options.html}`);
return;
}
await t.sendMail({ from, ...options });
}

View File

@@ -0,0 +1,14 @@
import { v4 as uuidv4 } from 'uuid';
export function generateSlug(title: string): string {
const base = title
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.substring(0, 200);
const suffix = uuidv4().split('-')[0]; // 8-char UUID segment
return `${base}-${suffix}`;
}

View File

@@ -0,0 +1,60 @@
import { ForbiddenException, Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import * as crypto from 'crypto';
const EXEMPT_PATHS = [
'/auth/login',
'/auth/register',
'/auth/magic-link',
'/auth/refresh',
'/auth/google',
'/auth/google/callback',
'/auth/password-reset',
'/health',
];
const STATE_CHANGING_METHODS = ['POST', 'PATCH', 'PUT', 'DELETE'];
@Injectable()
export class CsrfMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
// Read or generate CSRF token
let csrfToken: string = req.cookies?.csrfToken;
if (!csrfToken) {
csrfToken = crypto.randomBytes(24).toString('hex');
}
// Set as non-httpOnly cookie so JS can read it
// SameSite=none required for cross-subdomain (frontend ↔ backend on different subdomains)
const secure = process.env.COOKIE_SECURE === 'true';
const domain = process.env.COOKIE_DOMAIN || undefined;
const sameSite = secure ? ('none' as const) : ('lax' as const);
res.cookie('csrfToken', csrfToken, {
httpOnly: false,
sameSite,
secure,
domain,
});
(req as any).csrfToken = csrfToken;
// Validate on state-changing methods
if (STATE_CHANGING_METHODS.includes(req.method)) {
const isExempt = EXEMPT_PATHS.some((p) => req.path.startsWith(p));
if (!isExempt) {
const hasCookieAuth =
req.cookies?.accessToken || req.cookies?.refreshToken;
if (hasCookieAuth) {
const tokenFromHeader = req.headers['x-csrf-token'] as string;
const tokenFromBody = (req.body as any)?._csrf;
const provided = tokenFromHeader || tokenFromBody;
if (!provided || provided !== csrfToken) {
throw new ForbiddenException('Invalid CSRF token');
}
}
}
}
next();
}
}

View File

@@ -0,0 +1,131 @@
import { plainToClass, Transform } from 'class-transformer';
import { IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, validateSync } from 'class-validator';
enum Environment {
Development = 'development',
Production = 'production',
Test = 'test',
}
class EnvironmentVariables {
@IsEnum(Environment)
@IsOptional()
NODE_ENV: Environment = Environment.Development;
@IsString()
@IsOptional()
HOST: string = '0.0.0.0';
@IsNumber()
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
PORT: number = 3000;
@IsString()
@IsOptional()
APP_URL: string = 'http://localhost:3000';
@IsString()
@IsNotEmpty()
DB_HOST: string;
@IsNumber()
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
DB_PORT: number = 5432;
@IsString()
@IsNotEmpty()
DB_USER: string;
@IsString()
@IsNotEmpty()
DB_PASSWORD: string;
@IsString()
@IsNotEmpty()
DB_NAME: string;
@IsString()
@IsOptional()
DB_SSL: string = 'false';
@IsString()
@IsNotEmpty()
JWT_ACCESS_SECRET: string;
@IsString()
@IsNotEmpty()
JWT_REFRESH_SECRET: string;
@IsString()
@IsOptional()
JWT_ACCESS_EXPIRES_IN: string = '15m';
@IsString()
@IsOptional()
JWT_REFRESH_EXPIRES_IN: string = '7d';
@IsNumber()
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
MAGIC_LINK_TTL_MINUTES: number = 20;
@IsNumber()
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
PASSWORD_RESET_TTL_MINUTES: number = 30;
@IsString()
@IsOptional()
COOKIE_SECURE: string = 'false';
@IsString()
@IsOptional()
COOKIE_DOMAIN: string = '';
@IsString()
@IsOptional()
MAIL_FROM: string = 'no-reply@blog.local';
@IsString()
@IsOptional()
SMTP_HOST: string = '';
@IsNumber()
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
SMTP_PORT: number = 587;
@IsString()
@IsOptional()
SMTP_USER: string = '';
@IsString()
@IsOptional()
SMTP_PASS: string = '';
@IsString()
@IsOptional()
GOOGLE_CLIENT_ID: string = '';
@IsString()
@IsOptional()
GOOGLE_CLIENT_SECRET: string = '';
@IsString()
@IsOptional()
GOOGLE_CALLBACK_URL: string = 'http://localhost:3000/auth/google/callback';
}
export function validate(config: Record<string, unknown>) {
const validatedConfig = plainToClass(EnvironmentVariables, config, {
enableImplicitConversion: true,
});
const errors = validateSync(validatedConfig, { skipMissingProperties: false });
if (errors.length > 0) {
throw new Error(errors.toString());
}
return validatedConfig;
}

42
backend/src/main.ts Normal file
View File

@@ -0,0 +1,42 @@
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { ValidationPipe } from '@nestjs/common';
import { join } from 'path';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const cookieParser = require('cookie-parser');
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// ─── CORS — allow Next.js frontend ──────────────────────────────────────────
app.enableCors({
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
credentials: true,
methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'x-csrf-token'],
});
// ─── Static assets ──────────────────────────────────────────────────────────
app.useStaticAssets(join(process.cwd(), 'public'));
// ─── Middleware ──────────────────────────────────────────────────────────────
app.use(cookieParser());
// ─── Global pipes ────────────────────────────────────────────────────────────
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: false,
transform: true,
}),
);
// ─── Start ───────────────────────────────────────────────────────────────────
const port = process.env.PORT || 3000;
const host = process.env.HOST || '0.0.0.0';
await app.listen(port, host);
console.log(`🚀 Server running at http://${host}:${port}`);
}
bootstrap();

View File

@@ -0,0 +1,37 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Entity('magic_link_tokens')
export class MagicLinkToken {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id', nullable: true })
userId: string;
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ length: 255 })
email: string;
@Column({ name: 'token_hash', length: 255, unique: true })
tokenHash: string;
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt: Date;
@Column({ name: 'consumed_at', type: 'timestamptz', nullable: true })
consumedAt: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}

View File

@@ -0,0 +1,40 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Entity('oauth_accounts')
@Unique('uq_oauth_provider_provider_id', ['provider', 'providerId'])
export class OAuthAccount {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id' })
userId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ length: 50 })
provider: string;
@Column({ name: 'provider_id', length: 255 })
providerId: string;
@Column({ length: 255, nullable: true })
email: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@@ -0,0 +1,34 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Entity('password_reset_tokens')
export class PasswordResetToken {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id' })
userId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ name: 'token_hash', length: 255, unique: true })
tokenHash: string;
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt: Date;
@Column({ name: 'consumed_at', type: 'timestamptz', nullable: true })
consumedAt: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}

View File

@@ -0,0 +1,47 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Entity('refresh_tokens')
export class RefreshToken {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id' })
userId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ name: 'token_hash', length: 255, unique: true })
tokenHash: string;
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt: Date;
@Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })
revokedAt: Date;
@Column({ name: 'replaced_by_token_hash', nullable: true })
replacedByTokenHash: string;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent: string;
@Column({ name: 'ip', length: 64, nullable: true })
ip: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { RefreshToken } from './entities/refresh-token.entity';
import { MagicLinkToken } from './entities/magic-link-token.entity';
import { PasswordResetToken } from './entities/password-reset-token.entity';
import { OAuthAccount } from './entities/oauth-account.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
RefreshToken,
MagicLinkToken,
PasswordResetToken,
OAuthAccount,
]),
],
exports: [TypeOrmModule],
})
export class TokensModule {}

View File

@@ -0,0 +1,20 @@
import { IsEmail, IsEnum, IsOptional, IsString, MinLength } from 'class-validator';
import { UserRole } from '../entities/user.entity';
export class CreateUserDto {
@IsEmail()
email: string;
@IsString()
@IsOptional()
name?: string;
@IsEnum(UserRole)
@IsOptional()
role?: UserRole;
@IsString()
@MinLength(8)
@IsOptional()
password?: string;
}

View File

@@ -0,0 +1,8 @@
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class UpdateUserNameDto {
@IsString()
@MaxLength(120)
@IsOptional()
name?: string;
}

View File

@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { UserRole } from '../entities/user.entity';
export class UpdateUserRoleDto {
@IsEnum(UserRole)
role: UserRole;
}

View File

@@ -0,0 +1,41 @@
import {
Column,
CreateDateColumn,
Entity,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
export enum UserRole {
ADMIN = 'ADMIN',
MANAGER = 'MANAGER',
MEMBER = 'MEMBER',
}
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 255, unique: true })
email: string;
@Column({ length: 120, nullable: true })
name: string;
@Column({ type: 'varchar', length: 20, default: UserRole.MEMBER })
role: UserRole;
@Column({ name: 'password_hash', nullable: true })
passwordHash: string;
@Column({ name: 'is_active', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@@ -0,0 +1,64 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserNameDto } from './dto/update-user-name.dto';
import { UpdateUserRoleDto } from './dto/update-user-role.dto';
import { Roles } from '../common/decorators/roles.decorator';
import { UserRole } from './entities/user.entity';
@Controller('users')
@Roles(UserRole.ADMIN)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
async findAll(
@Query('page') page = '1',
@Query('pageSize') pageSize = '20',
@Query('q') q?: string,
) {
return this.usersService.findAll(parseInt(page, 10), parseInt(pageSize, 10), q);
}
@Post()
async create(@Body() dto: CreateUserDto) {
const user = await this.usersService.create(dto);
const { passwordHash, ...safe } = user as any;
return { success: true, user: safe };
}
@Patch(':id')
async updateName(
@Param('id') id: string,
@Body() dto: UpdateUserNameDto,
) {
const user = await this.usersService.updateName(id, dto);
const { passwordHash, ...safe } = user as any;
return { success: true, user: safe };
}
@Patch(':id/role')
async updateRole(
@Param('id') id: string,
@Body() dto: UpdateUserRoleDto,
) {
const user = await this.usersService.updateRole(id, dto);
const { passwordHash, ...safe } = user as any;
return { success: true, user: safe };
}
@Delete(':id')
async remove(@Param('id') id: string) {
await this.usersService.remove(id);
return { success: true };
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,91 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ConflictException, NotFoundException } from '@nestjs/common';
import { UsersService } from './users.service';
import { User, UserRole } from './entities/user.entity';
const mockUser: Partial<User> = {
id: 'uuid-1',
email: 'test@test.com',
name: 'Test User',
role: UserRole.MEMBER,
isActive: true,
passwordHash: 'hash',
};
const mockRepo = {
findAndCount: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
};
describe('UsersService', () => {
let service: UsersService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{ provide: getRepositoryToken(User), useValue: mockRepo },
],
}).compile();
service = module.get<UsersService>(UsersService);
jest.clearAllMocks();
});
describe('findAll', () => {
it('should return paginated users', async () => {
mockRepo.findAndCount.mockResolvedValue([[mockUser], 1]);
const result = await service.findAll(1, 20);
expect(result.users).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.totalPages).toBe(1);
});
});
describe('findById', () => {
it('should return user by id', async () => {
mockRepo.findOne.mockResolvedValue(mockUser);
const user = await service.findById('uuid-1');
expect(user.id).toBe('uuid-1');
});
it('should throw NotFoundException for unknown id', async () => {
mockRepo.findOne.mockResolvedValue(null);
await expect(service.findById('unknown')).rejects.toThrow(NotFoundException);
});
});
describe('create', () => {
it('should create a new user', async () => {
mockRepo.findOne.mockResolvedValue(null); // no existing user
mockRepo.create.mockReturnValue({ ...mockUser });
mockRepo.save.mockResolvedValue({ ...mockUser });
const result = await service.create({ email: 'new@test.com', password: 'password123' });
expect(mockRepo.save).toHaveBeenCalled();
});
it('should throw ConflictException for duplicate email', async () => {
mockRepo.findOne.mockResolvedValue(mockUser); // existing user
await expect(
service.create({ email: mockUser.email, password: 'password123' }),
).rejects.toThrow(ConflictException);
});
});
describe('updateRole', () => {
it('should update user role', async () => {
mockRepo.findOne.mockResolvedValue({ ...mockUser });
mockRepo.update.mockResolvedValue({});
mockRepo.findOne.mockResolvedValueOnce({ ...mockUser })
.mockResolvedValueOnce({ ...mockUser, role: UserRole.MANAGER });
const result = await service.updateRole('uuid-1', { role: UserRole.MANAGER });
expect(mockRepo.update).toHaveBeenCalledWith('uuid-1', { role: UserRole.MANAGER });
});
});
});

View File

@@ -0,0 +1,101 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ILike, Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { v4 as uuidv4 } from 'uuid';
import { User, UserRole } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserNameDto } from './dto/update-user-name.dto';
import { UpdateUserRoleDto } from './dto/update-user-role.dto';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly userRepo: Repository<User>,
) {}
async findAll(page = 1, pageSize = 20, q?: string) {
const where = q
? [{ email: ILike(`%${q}%`) }, { name: ILike(`%${q}%`) }]
: {};
const [users, total] = await this.userRepo.findAndCount({
where,
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
users,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
async findById(id: string): Promise<User> {
const user = await this.userRepo.findOne({ where: { id } });
if (!user) throw new NotFoundException(`User ${id} not found`);
return user;
}
async findByEmail(email: string): Promise<User | null> {
return this.userRepo.findOne({ where: { email } });
}
async create(dto: CreateUserDto): Promise<User> {
const existing = await this.findByEmail(dto.email);
if (existing) throw new ConflictException('Email already in use');
const user = this.userRepo.create({
id: uuidv4(),
email: dto.email,
name: dto.name,
role: dto.role || UserRole.MEMBER,
passwordHash: dto.password ? await bcrypt.hash(dto.password, 12) : null,
});
return this.userRepo.save(user);
}
async updateName(id: string, dto: UpdateUserNameDto): Promise<User> {
await this.findById(id);
await this.userRepo.update(id, { name: dto.name });
return this.findById(id);
}
async updateRole(id: string, dto: UpdateUserRoleDto): Promise<User> {
await this.findById(id);
await this.userRepo.update(id, { role: dto.role });
return this.findById(id);
}
async remove(id: string): Promise<void> {
await this.findById(id); // throws NotFoundException if not found
await this.userRepo.delete(id);
}
async setPassword(id: string, password: string): Promise<void> {
const hash = await bcrypt.hash(password, 12);
await this.userRepo.update(id, { passwordHash: hash });
}
async upsertByEmail(
email: string,
data: Partial<Pick<User, 'name' | 'role' | 'passwordHash' | 'isActive'>>,
): Promise<User> {
let user = await this.findByEmail(email);
if (!user) {
user = this.userRepo.create({ id: uuidv4(), email, ...data });
} else {
Object.assign(user, data);
}
return this.userRepo.save(user);
}
}

View File

@@ -0,0 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

View File

@@ -0,0 +1,141 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import * as cookieParser from 'cookie-parser';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AppModule } from '../src/app.module';
import { User } from '../src/users/entities/user.entity';
describe('Auth (e2e)', () => {
let app: INestApplication;
let userRepo: Repository<User>;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.use(cookieParser());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
userRepo = moduleFixture.get<Repository<User>>(getRepositoryToken(User));
});
afterAll(async () => {
await app.close();
});
const testEmail = `test-${Date.now()}@example.com`;
const testPassword = 'Test1234!';
describe('POST /auth/register', () => {
it('should register a new user and return tokens', async () => {
const res = await request(app.getHttpServer())
.post('/auth/register')
.send({ email: testEmail, password: testPassword, name: 'Test User' })
.expect(201);
expect(res.body.success).toBe(true);
expect(res.body.accessToken).toBeDefined();
expect(res.body.user.email).toBe(testEmail);
expect(res.body.user.passwordHash).toBeUndefined();
});
it('should reject duplicate email', async () => {
await request(app.getHttpServer())
.post('/auth/register')
.send({ email: testEmail, password: testPassword })
.expect(409);
});
it('should reject short password', async () => {
await request(app.getHttpServer())
.post('/auth/register')
.send({ email: 'new@example.com', password: '123' })
.expect(400);
});
});
describe('POST /auth/login', () => {
it('should login with correct credentials', async () => {
const res = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: testEmail, password: testPassword })
.expect(201);
expect(res.body.success).toBe(true);
expect(res.body.accessToken).toBeDefined();
// Cookies should be set
const cookies = res.headers['set-cookie'] as string[];
expect(cookies).toBeDefined();
const hasAccessCookie = cookies.some((c) => c.startsWith('accessToken='));
expect(hasAccessCookie).toBe(true);
});
it('should reject wrong password', async () => {
await request(app.getHttpServer())
.post('/auth/login')
.send({ email: testEmail, password: 'wrongpassword' })
.expect(401);
});
it('should reject non-existent email', async () => {
await request(app.getHttpServer())
.post('/auth/login')
.send({ email: 'nonexistent@example.com', password: testPassword })
.expect(401);
});
});
describe('POST /auth/refresh', () => {
it('should rotate refresh token', async () => {
// First login to get refresh token
const loginRes = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: testEmail, password: testPassword });
const cookies = loginRes.headers['set-cookie'] as string[];
const refreshCookie = cookies.find((c) => c.startsWith('refreshToken='));
expect(refreshCookie).toBeDefined();
// Use refresh token
const refreshRes = await request(app.getHttpServer())
.post('/auth/refresh')
.set('Cookie', cookies)
.expect(201);
expect(refreshRes.body.success).toBe(true);
expect(refreshRes.body.accessToken).toBeDefined();
});
it('should reject missing refresh token', async () => {
await request(app.getHttpServer())
.post('/auth/refresh')
.expect(401);
});
});
describe('POST /auth/magic-link', () => {
it('should accept magic link request for existing email', async () => {
const res = await request(app.getHttpServer())
.post('/auth/magic-link')
.send({ email: testEmail })
.expect(201);
expect(res.body.success).toBe(true);
});
it('should accept magic link request for non-existing email (no enumeration)', async () => {
const res = await request(app.getHttpServer())
.post('/auth/magic-link')
.send({ email: 'nobody@nowhere.com' })
.expect(201);
expect(res.body.success).toBe(true);
});
});
});

View File

@@ -0,0 +1,12 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"transformIgnorePatterns": [
"/node_modules/(?!(uuid)/)"
]
}

View File

@@ -0,0 +1,133 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import * as cookieParser from 'cookie-parser';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { v4 as uuidv4 } from 'uuid';
import { AppModule } from '../src/app.module';
import { BlogPost, PostStatus, ContentFormat } from '../src/blog-posts/entities/blog-post.entity';
import { User, UserRole } from '../src/users/entities/user.entity';
describe('Public Posts (e2e)', () => {
let app: INestApplication;
let postRepo: Repository<BlogPost>;
let userRepo: Repository<User>;
let testAuthorId: string;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.use(cookieParser());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
postRepo = moduleFixture.get<Repository<BlogPost>>(getRepositoryToken(BlogPost));
userRepo = moduleFixture.get<Repository<User>>(getRepositoryToken(User));
// Create test author
const author = userRepo.create({
id: uuidv4(),
email: `author-${Date.now()}@test.com`,
role: UserRole.ADMIN,
isActive: true,
});
await userRepo.save(author);
testAuthorId = author.id;
// Create test posts
await postRepo.save([
postRepo.create({
id: uuidv4(),
title: 'Published Test Post',
slug: `published-test-${Date.now()}`,
status: PostStatus.PUBLISHED,
excerpt: 'A published post for testing',
content: 'Test content',
contentFormat: ContentFormat.MARKDOWN,
authorId: testAuthorId,
tags: ['test', 'published'],
categories: ['Test'],
}),
postRepo.create({
id: uuidv4(),
title: 'Draft Test Post',
slug: `draft-test-${Date.now()}`,
status: PostStatus.DRAFT,
excerpt: 'A draft post',
content: 'Draft content',
contentFormat: ContentFormat.MARKDOWN,
authorId: testAuthorId,
tags: ['test', 'draft'],
categories: ['Test'],
}),
]);
});
afterAll(async () => {
await app.close();
});
describe('GET /blog-posts/public', () => {
it('should return only published posts', async () => {
const res = await request(app.getHttpServer())
.get('/blog-posts/public')
.expect(200);
expect(Array.isArray(res.body.posts)).toBe(true);
const allPublished = res.body.posts.every((p: any) => p.status === 'published');
expect(allPublished).toBe(true);
});
it('should support search by query', async () => {
const res = await request(app.getHttpServer())
.get('/blog-posts/public?q=Published+Test')
.expect(200);
expect(res.body.posts.length).toBeGreaterThanOrEqual(1);
});
it('should support pagination', async () => {
const res = await request(app.getHttpServer())
.get('/blog-posts/public?page=1&pageSize=2')
.expect(200);
expect(res.body.posts.length).toBeLessThanOrEqual(2);
expect(res.body.page).toBe(1);
});
});
describe('GET /blog-posts/public/:slug', () => {
it('should return a published post by slug', async () => {
// First get a published post slug
const listRes = await request(app.getHttpServer()).get('/blog-posts/public');
const slug = listRes.body.posts[0]?.slug;
expect(slug).toBeDefined();
const res = await request(app.getHttpServer())
.get(`/blog-posts/public/${slug}`)
.expect(200);
expect(res.body.post.slug).toBe(slug);
});
it('should return 404 for draft post', async () => {
await request(app.getHttpServer())
.get(`/blog-posts/public/draft-test-99999`)
.expect(404);
});
});
describe('GET /blog-posts/public/featured', () => {
it('should return featured published posts', async () => {
const res = await request(app.getHttpServer())
.get('/blog-posts/public/featured')
.expect(200);
expect(Array.isArray(res.body)).toBe(true);
});
});
});

View File

@@ -0,0 +1,125 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import * as cookieParser from 'cookie-parser';
import { AppModule } from '../src/app.module';
describe('RBAC (e2e)', () => {
let app: INestApplication;
// We'll store cookies per user role
let adminCookies: string[];
let managerCookies: string[];
let memberCookies: string[];
const ts = Date.now();
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.use(cookieParser());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
// Register three users, then promote via direct DB or use existing seed
// For simplicity, register all as MEMBER — we test permission differences
const adminEmail = `admin-rbac-${ts}@test.com`;
const managerEmail = `manager-rbac-${ts}@test.com`;
const memberEmail = `member-rbac-${ts}@test.com`;
const pw = 'RbacTest123!';
// Register all
await request(app.getHttpServer())
.post('/auth/register')
.send({ email: adminEmail, password: pw });
await request(app.getHttpServer())
.post('/auth/register')
.send({ email: managerEmail, password: pw });
const memberReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ email: memberEmail, password: pw });
// Store member cookies
memberCookies = memberReg.headers['set-cookie'] as string[];
// Login as the newly registered users
const adminLogin = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: adminEmail, password: pw });
adminCookies = adminLogin.headers['set-cookie'] as string[];
const managerLogin = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: managerEmail, password: pw });
managerCookies = managerLogin.headers['set-cookie'] as string[];
});
afterAll(async () => {
await app.close();
});
describe('MEMBER permissions', () => {
it('should NOT be able to create a blog post', async () => {
await request(app.getHttpServer())
.post('/blog-posts')
.set('Cookie', memberCookies)
.send({ title: 'Member Post', content: 'Content' })
.expect(403);
});
it('should NOT be able to list users', async () => {
await request(app.getHttpServer())
.get('/users')
.set('Cookie', memberCookies)
.expect(403);
});
it('should be able to view dashboard', async () => {
await request(app.getHttpServer())
.get('/dashboard')
.set('Cookie', memberCookies)
.expect(200);
});
});
describe('Unauthenticated access', () => {
it('should allow access to public posts', async () => {
await request(app.getHttpServer())
.get('/blog-posts/public')
.expect(200);
});
it('should deny access to dashboard', async () => {
await request(app.getHttpServer())
.get('/dashboard')
.expect(401);
});
it('should deny access to users list', async () => {
await request(app.getHttpServer())
.get('/users')
.expect(401);
});
});
describe('Public endpoints', () => {
it('GET /health should be publicly accessible', async () => {
const res = await request(app.getHttpServer())
.get('/health')
.expect(200);
expect(res.body.status).toBe('ok');
});
it('GET / should be publicly accessible', async () => {
await request(app.getHttpServer())
.get('/')
.expect(200);
});
});
});

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

26
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"esModuleInterop": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false,
"forceConsistentCasingInFileNames": true,
"paths": {
"@/*": ["src/*"]
}
}
}

52
docker-compose.yml Normal file
View File

@@ -0,0 +1,52 @@
version: '3.9'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
restart: unless-stopped
environment:
NODE_ENV: ${NODE_ENV}
HOST: ${HOST}
PORT: ${BACKEND_PORT}
APP_URL: ${APP_URL}
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
DB_SSL: ${DB_SSL}
JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
JWT_ACCESS_EXPIRES_IN: ${JWT_ACCESS_EXPIRES_IN}
JWT_REFRESH_EXPIRES_IN: ${JWT_REFRESH_EXPIRES_IN}
MAGIC_LINK_TTL_MINUTES: ${MAGIC_LINK_TTL_MINUTES}
PASSWORD_RESET_TTL_MINUTES: ${PASSWORD_RESET_TTL_MINUTES}
COOKIE_SECURE: ${COOKIE_SECURE}
COOKIE_DOMAIN: ${COOKIE_DOMAIN}
MAIL_FROM: ${MAIL_FROM}
SMTP_HOST: ${SMTP_HOST}
SMTP_PORT: ${SMTP_PORT}
SMTP_USER: ${SMTP_USER}
SMTP_PASS: ${SMTP_PASS}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
GOOGLE_CALLBACK_URL: ${GOOGLE_CALLBACK_URL}
FRONTEND_URL: ${FRONTEND_URL}
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
UPLOAD_R2_WORKER_API: ${UPLOAD_R2_WORKER_API}
R2_UPLOAD_API_KEY: ${R2_UPLOAD_API_KEY}
restart: unless-stopped
environment:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
UPLOAD_R2_WORKER_API: ${UPLOAD_R2_WORKER_API}
R2_UPLOAD_API_KEY: ${R2_UPLOAD_API_KEY}
depends_on:
- backend

13
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
node_modules
npm-debug.log
Dockerfile*
docker-compose*
.dockerignore
.git
.gitignore
README.md
LICENSE
.vscode
.next
*.swp
/scripts

4
frontend/.env.example Normal file
View File

@@ -0,0 +1,4 @@
NEXT_PUBLIC_API_URL=http://localhost:5001
UPLOAD_R2_WORKER_API=***.workers.dev
R2_UPLOAD_API_KEY=***

41
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

25
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# Build args for NEXT_PUBLIC_ vars (baked in at build time)
ARG NEXT_PUBLIC_API_URL
ARG UPLOAD_R2_WORKER_API
ARG R2_UPLOAD_API_KEY
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV UPLOAD_R2_WORKER_API=$UPLOAD_R2_WORKER_API
ENV R2_UPLOAD_API_KEY=$R2_UPLOAD_API_KEY
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=base /app/.next ./.next
COPY --from=base /app/public ./public
EXPOSE 3000
CMD ["npm", "start"]

View File

@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from "next/server";
const R2_WORKER = process.env.UPLOAD_R2_WORKER_API;
const R2_API_KEY = process.env.R2_UPLOAD_API_KEY;
export interface UploadImageResponse {
success: true;
url: string;
key: string;
contentType: string;
}
/**
* POST /api/upload-image
*
* Accepts a multipart/form-data body with a single "file" field.
* Proxies the binary to the Cloudflare R2 worker using the server-side API key
* (the key is never exposed to the browser).
*
* Returns: { success, url, key, contentType }
*/
export async function POST(req: NextRequest) {
console.log(R2_WORKER);
console.log(R2_API_KEY);
if (!R2_WORKER || !R2_API_KEY) {
return NextResponse.json(
{ error: "R2 upload is not configured. Set UPLOAD_R2_WORKER_API and R2_UPLOAD_API_KEY." },
{ status: 503 }
);
}
try {
const formData = await req.formData();
const file = formData.get("file");
if (!file || !(file instanceof File)) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
// Validate file type
const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", "image/avif"];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: `Unsupported file type: ${file.type}. Allowed: JPEG, PNG, GIF, WebP, SVG, AVIF.` },
{ status: 400 }
);
}
// Validate file size (max 10 MB)
const MAX_BYTES = 10 * 1024 * 1024;
if (file.size > MAX_BYTES) {
return NextResponse.json(
{ error: `File is too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum is 10 MB.` },
{ status: 400 }
);
}
// Send binary to R2 worker
const arrayBuffer = await file.arrayBuffer();
const r2Res = await fetch(`${R2_WORKER}/upload`, {
method: "POST",
headers: {
"X-Api-Key": R2_API_KEY,
"Content-Type": file.type,
},
body: arrayBuffer,
});
if (!r2Res.ok) {
let errMsg = `R2 worker error ${r2Res.status}`;
try {
const body = await r2Res.json();
errMsg = body?.error || body?.message || errMsg;
} catch {
/* ignore */
}
return NextResponse.json({ error: errMsg }, { status: 502 });
}
const data = (await r2Res.json()) as UploadImageResponse;
return NextResponse.json(data);
} catch (err) {
console.error("[upload-image] Unexpected error:", err);
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
}
}

439
frontend/app/auth/page.tsx Normal file
View File

@@ -0,0 +1,439 @@
"use client";
import { useState, useEffect, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/lib/auth";
import { clientFetch } from "@/lib/api";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Eye, EyeOff, Loader2, LogIn, UserPlus, KeyRound, Mail } from "lucide-react";
import { API_URL } from "@/lib/api";
import { UserRole } from "@/lib/types";
// Default export wraps the inner component in Suspense (required by Next.js
// when useSearchParams() is used inside a "use client" page).
export default function AuthPage() {
return (
<Suspense>
<AuthPageInner />
</Suspense>
);
}
function AuthPageInner() {
const { currentUser, login, register, refresh } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const resetToken = searchParams.get("token") || "";
const initialTab = resetToken ? "reset" : "signin";
const [loading, setLoading] = useState(false);
const [showPwd, setShowPwd] = useState(false);
// Redirect if already logged in
useEffect(() => {
if (currentUser) router.replace("/dashboard");
}, [currentUser, router]);
// ── Sign In ───────────────────────────────────────────────────────────────
const handleSignIn = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const email = fd.get("email") as string;
const password = fd.get("password") as string;
setLoading(true);
try {
await login(email, password);
toast.success("Welcome back!");
router.push("/dashboard");
router.refresh();
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Login failed");
} finally {
setLoading(false);
}
};
// ── Sign Up ───────────────────────────────────────────────────────────────
const handleSignUp = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const name = fd.get("name") as string;
const email = fd.get("email") as string;
const password = fd.get("password") as string;
setLoading(true);
try {
await register(name, email, password);
toast.success("Account created! Welcome!");
router.push("/dashboard");
router.refresh();
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Registration failed");
} finally {
setLoading(false);
}
};
// ── Magic Link ────────────────────────────────────────────────────────────
const handleMagicLink = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const email = fd.get("email") as string;
setLoading(true);
try {
await clientFetch("/auth/magic-link", {
method: "POST",
body: JSON.stringify({ email }),
});
toast.success("Magic link sent! Check your email.");
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Failed to send magic link");
} finally {
setLoading(false);
}
};
// ── Password Reset Request ────────────────────────────────────────────────
const handleResetRequest = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const email = fd.get("email") as string;
setLoading(true);
try {
await clientFetch("/auth/password-reset/request", {
method: "POST",
body: JSON.stringify({ email }),
});
toast.success("Reset link sent! Check your email.");
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Failed");
} finally {
setLoading(false);
}
};
// ── Password Reset Confirm ────────────────────────────────────────────────
const handleResetConfirm = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const token = fd.get("token") as string;
const password = fd.get("password") as string;
setLoading(true);
try {
await clientFetch("/auth/password-reset/confirm", {
method: "POST",
body: JSON.stringify({ token, password }),
});
toast.success("Password updated! You can now sign in.");
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Failed to reset password");
} finally {
setLoading(false);
}
};
return (
<div className="mx-auto max-w-lg space-y-5">
<Tabs defaultValue={initialTab}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="signin">Sign In</TabsTrigger>
<TabsTrigger value="signup">Sign Up</TabsTrigger>
<TabsTrigger value="reset">Reset</TabsTrigger>
</TabsList>
{/* ── Sign In Tab ─────────────────────────────────────────────────── */}
<TabsContent value="signin">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<LogIn className="h-5 w-5" /> Sign In
</CardTitle>
<CardDescription>
Sign in to your account to access the dashboard.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="signin-email">Email</Label>
<Input
id="signin-email"
name="email"
type="email"
placeholder="you@example.com"
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="signin-password">Password</Label>
<div className="flex gap-2">
<Input
id="signin-password"
name="password"
type={showPwd ? "text" : "password"}
placeholder="Enter your password"
required
autoComplete="current-password"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setShowPwd((v) => !v)}
>
{showPwd ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Sign In
</Button>
</form>
<Separator />
<form onSubmit={handleMagicLink} className="space-y-2">
<Input
id="magic-email"
name="email"
type="email"
placeholder="Magic link: enter your email"
/>
<Button
type="submit"
variant="outline"
className="w-full"
disabled={loading}
>
<Mail className="mr-2 h-4 w-4" />
Send magic link
</Button>
</form>
<Button
variant="outline"
className="w-full"
onClick={() =>
(window.location.href = `${API_URL}/auth/google`)
}
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continue with Google
</Button>
</CardContent>
</Card>
</TabsContent>
{/* ── Sign Up Tab ─────────────────────────────────────────────────── */}
<TabsContent value="signup">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5" /> Create Account
</CardTitle>
<CardDescription>
New sign-ups are assigned the default MEMBER role.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSignUp} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="signup-name">Full Name</Label>
<Input
id="signup-name"
name="name"
placeholder="Your full name"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="signup-email">Email</Label>
<Input
id="signup-email"
name="email"
type="email"
placeholder="you@example.com"
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="signup-password">Password</Label>
<div className="flex gap-2">
<Input
id="signup-password"
name="password"
type={showPwd ? "text" : "password"}
placeholder="Create a password"
required
autoComplete="new-password"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setShowPwd((v) => !v)}
>
{showPwd ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Account
</Button>
</form>
</CardContent>
</Card>
</TabsContent>
{/* ── Reset Password Tab ──────────────────────────────────────────── */}
<TabsContent value="reset">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="h-5 w-5" /> Reset Password
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Step 1: Request */}
<form onSubmit={handleResetRequest} className="space-y-3">
<Label>Request reset link</Label>
<div className="flex gap-2">
<Input name="email" type="email" placeholder="your@email.com" />
<Button type="submit" variant="outline" disabled={loading}>
{loading && (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
)}
Send
</Button>
</div>
</form>
<Separator />
{/* Step 2: Confirm */}
<form onSubmit={handleResetConfirm} className="space-y-3">
<Label>Confirm new password</Label>
<Input
name="token"
placeholder="Reset token from email"
defaultValue={resetToken}
required
/>
<div className="flex gap-2">
<Input
name="password"
type={showPwd ? "text" : "password"}
placeholder="New password"
required
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setShowPwd((v) => !v)}
>
{showPwd ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Update Password
</Button>
</form>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* ── Demo credentials card ─────────────────────────────────────────── */}
<Card className="border-dashed">
<CardContent className="pt-5 space-y-3">
<div className="rounded-lg bg-muted p-4 space-y-1">
<p className="font-semibold">Demo Admin Account</p>
<p className="text-sm text-muted-foreground">
Email: <strong>admin@gmail.com</strong>
</p>
<p className="text-sm text-muted-foreground">
Password: <strong>Whatever123$</strong>
</p>
<Button
size="sm"
variant="secondary"
className="mt-2"
onClick={() => {
const emailInput = document.getElementById(
"signin-email"
) as HTMLInputElement | null;
const pwdInput = document.getElementById(
"signin-password"
) as HTMLInputElement | null;
if (emailInput) emailInput.value = "admin@gmail.com";
if (pwdInput) pwdInput.value = "Whatever123$";
document.querySelector<HTMLButtonElement>('[value="signin"]')?.click();
}}
>
Use these credentials
</Button>
</div>
<div className="space-y-2 text-sm">
<p className="font-semibold">Role Permissions</p>
<div className="space-y-1 text-muted-foreground">
<p>
<span className="font-medium text-blue-600">MEMBER</span> View
blog posts only
</p>
<p>
<span className="font-medium text-amber-600">MANAGER</span>
View all + create (draft only)
</p>
<p>
<span className="font-medium text-red-600">ADMIN</span> Full
CRUD access + user management
</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,308 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { marked } from "marked";
import { apiFetch } from "@/lib/api";
import { BlogPost, PaginatedResult, TagCloudItem } from "@/lib/types";
import { ViewTracker } from "./view-tracker";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Eye, TrendingUp, ArrowLeft, Calendar } from "lucide-react";
import { Button } from "@/components/ui/button";
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props) {
const { slug } = await params;
try {
const { post } = await apiFetch<{ post: BlogPost }>(
`/blog-posts/public/${slug}`
);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: post.featuredImageUrl ? [post.featuredImageUrl] : [],
},
};
} catch {
return { title: "Post not found" };
}
}
async function getPost(slug: string): Promise<BlogPost | null> {
try {
const data = await apiFetch<{ success: boolean; post: BlogPost }>(
`/blog-posts/public/${slug}`
);
return data.post;
} catch {
return null;
}
}
async function getRelatedPosts(post: BlogPost): Promise<BlogPost[]> {
try {
// Get posts with shared category
const cat = post.categories[0];
if (cat) {
const result = await apiFetch<PaginatedResult<BlogPost>>(
`/blog-posts/public?category=${encodeURIComponent(cat)}&pageSize=4`
);
const filtered = result.items.filter((p) => p.id !== post.id);
if (filtered.length > 0) return filtered.slice(0, 4);
}
// Fallback: latest
const result = await apiFetch<PaginatedResult<BlogPost>>(
"/blog-posts/public?pageSize=4"
);
return result.items.filter((p) => p.id !== post.id).slice(0, 4);
} catch {
return [];
}
}
async function getPopularPosts(excludeId: string): Promise<BlogPost[]> {
try {
const result = await apiFetch<PaginatedResult<BlogPost>>(
"/blog-posts/public?sort=most_viewed&pageSize=6"
);
return result.items.filter((p) => p.id !== excludeId).slice(0, 5);
} catch {
return [];
}
}
async function getTopTags(): Promise<TagCloudItem[]> {
try {
const result = await apiFetch<PaginatedResult<BlogPost>>(
"/blog-posts/public?pageSize=100"
);
const counts: Record<string, number> = {};
for (const post of result.items) {
for (const tag of post.tags || []) {
const t = tag.trim();
if (t) counts[t] = (counts[t] || 0) + 1;
}
}
return Object.entries(counts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 20);
} catch {
return [];
}
}
export default async function BlogDetailPage({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) return notFound();
const [relatedPosts, popularPosts, topTags] = await Promise.all([
getRelatedPosts(post),
getPopularPosts(post.id),
getTopTags(),
]);
const contentHtml =
post.contentFormat === "markdown"
? await marked(post.content)
: post.content;
const publishedDate = new Date(post.createdAt).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
return (
<div className="space-y-6">
<ViewTracker slug={slug} />
{/* ── Back button ────────────────────────────────────────────────────── */}
<Button asChild variant="ghost" size="sm" className="-ml-2">
<Link href="/">
<ArrowLeft className="h-4 w-4 mr-1" /> Back to Blog
</Link>
</Button>
{/* ── Article ────────────────────────────────────────────────────────── */}
<article className="rounded-2xl border bg-card p-5 shadow-sm">
{/* Categories */}
{post.categories.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
{post.categories.map((cat) => (
<Badge key={cat} variant="secondary" className="uppercase tracking-wide text-xs">
{cat}
</Badge>
))}
</div>
)}
<h1 className="text-3xl font-bold leading-tight">{post.title}</h1>
{post.excerpt && (
<p className="mt-3 text-lg text-muted-foreground">{post.excerpt}</p>
)}
{/* Meta */}
<div className="mt-3 flex flex-wrap gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" /> {publishedDate}
</span>
<span className="flex items-center gap-1">
<Eye className="h-3 w-3" /> {post.views.toLocaleString()} views
</span>
{post.author && (
<span>by {post.author.name || post.author.email}</span>
)}
</div>
{/* Featured image */}
{post.featuredImageUrl && (
<div className="mt-5 relative h-64 md:h-96 w-full overflow-hidden rounded-xl">
<Image
src={post.featuredImageUrl}
alt={post.featuredImageAlt || post.title}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 800px"
priority
/>
</div>
)}
{/* Tags */}
{post.tags.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{post.tags.map((tag) => (
<Link key={tag} href={`/?tags=${encodeURIComponent(tag)}`}>
<Badge variant="outline" className="cursor-pointer hover:bg-muted">
#{tag}
</Badge>
</Link>
))}
</div>
)}
<Separator className="my-6" />
{/* Content */}
<div
className="prose prose-zinc dark:prose-invert max-w-none
prose-headings:font-bold prose-headings:tracking-tight
prose-h1:text-3xl prose-h2:text-2xl prose-h3:text-xl
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded
prose-pre:bg-muted prose-pre:border prose-pre:rounded-lg
prose-blockquote:border-l-4 prose-blockquote:border-muted-foreground/30
prose-img:rounded-xl prose-img:border"
dangerouslySetInnerHTML={{ __html: contentHtml }}
/>
</article>
{/* ── Related + Sidebar ──────────────────────────────────────────────── */}
<div className="grid gap-6 lg:grid-cols-[1fr_20rem]">
{/* Related posts */}
<section className="rounded-2xl border bg-card p-5">
<h2 className="mb-4 text-xl font-bold">More from the blog</h2>
{relatedPosts.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2">
{relatedPosts.map((related) => (
<article
key={related.id}
className="rounded-xl border bg-muted/30 p-4"
>
{related.categories.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1">
{related.categories.slice(0, 2).map((cat) => (
<Badge
key={cat}
variant="secondary"
className="text-xs uppercase"
>
{cat}
</Badge>
))}
</div>
)}
<Link href={`/blog/${related.slug}`} className="group">
<h3 className="font-semibold leading-tight group-hover:text-primary transition-colors line-clamp-2">
{related.title}
</h3>
</Link>
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
{related.excerpt}
</p>
<p className="mt-1 text-xs text-muted-foreground flex items-center gap-1">
<Eye className="h-3 w-3" /> {related.views.toLocaleString()}
</p>
</article>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
No related posts yet.
</p>
)}
</section>
{/* Sidebar */}
<aside className="space-y-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<TrendingUp className="h-4 w-4" /> Popular Posts
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{popularPosts.map((p) => (
<Link
key={p.slug}
href={`/blog/${p.slug}`}
className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm font-medium hover:bg-muted transition-colors"
>
<span className="line-clamp-1 mr-2">{p.title}</span>
<span className="text-muted-foreground shrink-0 flex items-center gap-1">
<Eye className="h-3 w-3" />
{p.views.toLocaleString()}
</span>
</Link>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Tags</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{topTags.map((tag) => (
<Link
key={tag.name}
href={`/?tags=${encodeURIComponent(tag.name)}`}
>
<Badge
variant="outline"
className="cursor-pointer hover:bg-muted transition-colors"
>
#{tag.name}
</Badge>
</Link>
))}
</div>
</CardContent>
</Card>
</aside>
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
"use client";
import { useEffect } from "react";
import { clientFetch } from "@/lib/api";
export function ViewTracker({ slug }: { slug: string }) {
useEffect(() => {
clientFetch(`/blog-posts/public/${slug}/view`, { method: "POST" }).catch(
() => {}
);
}, [slug]);
return null;
}

View File

@@ -0,0 +1,373 @@
"use client";
import { useEffect, useState } from "react";
import { clientFetch } from "@/lib/api";
import { toast } from "sonner";
import { BlogPost, PostStatus, ContentFormat, UserRole } from "@/lib/types";
import { TiptapEditor } from "@/components/dashboard/tiptap-editor";
import { ImageUploader } from "@/components/dashboard/image-uploader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "@/components/ui/sheet";
import { Separator } from "@/components/ui/separator";
import { Loader2, FileText, Settings2 } from "lucide-react";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
/** If provided, the sheet is in edit mode. Otherwise, create mode. */
post?: BlogPost | null;
userRole: UserRole;
onSuccess: () => void;
}
const defaultStatus = (role: UserRole): PostStatus =>
role === UserRole.ADMIN ? PostStatus.PUBLISHED : PostStatus.DRAFT;
export function PostSheet({ open, onOpenChange, post, userRole, onSuccess }: Props) {
const isEdit = !!post;
/* ── form state ─────────────────────────────────────────────────────────── */
const [title, setTitle] = useState("");
const [slug, setSlug] = useState("");
const [excerpt, setExcerpt] = useState("");
const [content, setContent] = useState("");
const [contentFormat, setContentFormat] = useState<ContentFormat>(ContentFormat.HTML);
const [status, setStatus] = useState<PostStatus>(defaultStatus(userRole));
const [isFeatured, setIsFeatured] = useState(false);
const [categories, setCategories] = useState("");
const [tags, setTags] = useState("");
const [imageUrl, setImageUrl] = useState("");
const [imageAlt, setImageAlt] = useState("");
const [loading, setLoading] = useState(false);
/* ── populate when sheet opens ──────────────────────────────────────────── */
useEffect(() => {
if (!open) return;
if (post) {
setTitle(post.title);
setSlug(post.slug);
setExcerpt(post.excerpt ?? "");
setContent(post.content ?? "");
setContentFormat(post.contentFormat);
setStatus(post.status);
setIsFeatured(post.isFeatured);
setCategories(post.categories.join(","));
setTags(post.tags.join(","));
setImageUrl(post.featuredImageUrl ?? "");
setImageAlt(post.featuredImageAlt ?? "");
} else {
setTitle("");
setSlug("");
setExcerpt("");
setContent("");
setContentFormat(ContentFormat.HTML);
setStatus(defaultStatus(userRole));
setIsFeatured(false);
setCategories("");
setTags("");
setImageUrl("");
setImageAlt("");
}
}, [open, post, userRole]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!content || content === "<p></p>") {
toast.error("Content cannot be empty.");
return;
}
const body = {
title: title.trim(),
slug: slug.trim() || undefined,
excerpt: excerpt.trim() || undefined,
content,
contentFormat,
status: userRole !== UserRole.ADMIN ? PostStatus.DRAFT : status,
isFeatured,
categories: categories.trim() || undefined,
tags: tags.trim() || undefined,
featuredImageUrl: imageUrl || undefined,
featuredImageAlt: imageAlt || undefined,
};
setLoading(true);
try {
if (isEdit && post) {
await clientFetch(`/blog-posts/${post.id}`, {
method: "PATCH",
body: JSON.stringify(body),
});
toast.success("Post updated!");
} else {
await clientFetch("/blog-posts", {
method: "POST",
body: JSON.stringify(body),
});
toast.success("Post created!");
}
onOpenChange(false);
onSuccess();
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Something went wrong");
} finally {
setLoading(false);
}
};
const statusColor: Record<PostStatus, string> = {
[PostStatus.PUBLISHED]: "text-emerald-600",
[PostStatus.DRAFT]: "text-zinc-500",
[PostStatus.ARCHIVED]: "text-amber-600",
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
{/* Wide sheet — takes 60% of screen on large displays */}
<SheetContent
side="right"
className="w-full sm:max-w-2xl lg:max-w-3xl flex flex-col p-0 gap-0"
>
{/* ── Sheet header ──────────────────────────────────────────────────── */}
<SheetHeader className="px-6 py-4 border-b shrink-0">
<SheetTitle className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
{isEdit ? "Edit Post" : "New Post"}
</SheetTitle>
<SheetDescription>
{isEdit
? `Editing "${post?.title}"`
: "Fill in the details below and hit Publish (or save as Draft)."}
</SheetDescription>
</SheetHeader>
{/* ── Scrollable form body ──────────────────────────────────────────── */}
<form
id="post-sheet-form"
onSubmit={handleSubmit}
className="flex-1 overflow-y-auto"
>
<div className="px-6 py-5 space-y-5">
{/* Title + Slug */}
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="ps-title">
Title <span className="text-destructive">*</span>
</Label>
<Input
id="ps-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="My awesome post"
required
className="text-base font-medium"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ps-slug" className="text-muted-foreground text-xs">
Slug <span className="text-muted-foreground/60">(auto-generated if empty)</span>
</Label>
<Input
id="ps-slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="my-awesome-post"
className="font-mono text-xs"
/>
</div>
</div>
<Separator />
{/* Content editor */}
<div className="space-y-1.5">
<Label>
Content <span className="text-destructive">*</span>
</Label>
<TiptapEditor
value={content}
onChange={setContent}
placeholder="Write your post content here…"
minHeight="340px"
/>
</div>
{/* Excerpt */}
<div className="space-y-1.5">
<Label htmlFor="ps-excerpt">Excerpt</Label>
<Textarea
id="ps-excerpt"
value={excerpt}
onChange={(e) => setExcerpt(e.target.value)}
placeholder="A short description shown in post listings…"
rows={2}
className="resize-none"
/>
</div>
<Separator />
{/* Settings row */}
<div className="space-y-3">
<div className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<Settings2 className="h-3.5 w-3.5" />
Post settings
</div>
<div className="grid gap-3 sm:grid-cols-2">
{/* Status — admin only */}
{userRole === UserRole.ADMIN ? (
<div className="space-y-1.5">
<Label>Status</Label>
<Select
value={status}
onValueChange={(v) => setStatus(v as PostStatus)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={PostStatus.DRAFT}>
<span className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-zinc-400" />
Draft
</span>
</SelectItem>
<SelectItem value={PostStatus.PUBLISHED}>
<span className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-emerald-500" />
Published
</span>
</SelectItem>
<SelectItem value={PostStatus.ARCHIVED}>
<span className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-amber-500" />
Archived
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
) : (
<div className="space-y-1.5">
<Label className="text-muted-foreground">Status</Label>
<div className="flex h-9 items-center text-sm text-muted-foreground border rounded-md px-3">
Saved as Draft (Manager)
</div>
</div>
)}
{/* Format */}
<div className="space-y-1.5">
<Label>Content format</Label>
<Select
value={contentFormat}
onValueChange={(v) => setContentFormat(v as ContentFormat)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ContentFormat.HTML}>HTML (Tiptap)</SelectItem>
<SelectItem value={ContentFormat.MARKDOWN}>Markdown</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Categories + Tags */}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="ps-cats">Categories</Label>
<Input
id="ps-cats"
value={categories}
onChange={(e) => setCategories(e.target.value)}
placeholder="tech, tutorials"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ps-tags">Tags</Label>
<Input
id="ps-tags"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="nextjs, react"
/>
</div>
</div>
{/* Featured toggle */}
{userRole === UserRole.ADMIN && (
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
<div>
<p className="text-sm font-medium">Featured post</p>
<p className="text-xs text-muted-foreground">
Shown in the hero/featured section on the home page
</p>
</div>
<Switch
checked={isFeatured}
onCheckedChange={setIsFeatured}
/>
</div>
)}
</div>
<Separator />
{/* Featured image */}
<ImageUploader
value={imageUrl}
onChange={setImageUrl}
altValue={imageAlt}
onAltChange={setImageAlt}
label="Featured Image"
/>
</div>
</form>
{/* ── Sticky footer ────────────────────────────────────────────────── */}
<div className="border-t px-6 py-4 flex items-center justify-between gap-3 bg-background shrink-0">
{isEdit && (
<span className={`text-xs font-medium capitalize ${statusColor[status]}`}>
{status}
</span>
)}
<div className="flex gap-2 ml-auto">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" form="post-sheet-form" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEdit ? "Save changes" : status === PostStatus.PUBLISHED ? "Publish" : "Save Draft"}
</Button>
</div>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,293 @@
"use client";
import { useState, useCallback } from "react";
import { clientFetch } from "@/lib/api";
import { toast } from "sonner";
import { BlogPost, PostStatus, UserRole } from "@/lib/types";
import { PostSheet } from "./post-sheet";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
Eye,
Loader2,
Pencil,
Trash2,
PlusCircle,
ExternalLink,
Star,
Search,
} from "lucide-react";
import Link from "next/link";
interface Props {
initialPosts: BlogPost[];
userRole: UserRole;
}
const statusMeta: Record<PostStatus, { label: string; dot: string; row: string }> = {
[PostStatus.PUBLISHED]: {
label: "Published",
dot: "bg-emerald-500",
row: "border-l-emerald-400",
},
[PostStatus.DRAFT]: {
label: "Draft",
dot: "bg-zinc-400",
row: "border-l-zinc-300",
},
[PostStatus.ARCHIVED]: {
label: "Archived",
dot: "bg-amber-400",
row: "border-l-amber-400",
},
};
export function PostsTable({ initialPosts, userRole }: Props) {
const [posts, setPosts] = useState<BlogPost[]>(initialPosts);
const [sheetOpen, setSheetOpen] = useState(false);
const [editingPost, setEditingPost] = useState<BlogPost | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const refreshPosts = useCallback(async () => {
try {
const data = await clientFetch<{ posts: BlogPost[]; total: number }>(
"/blog-posts?pageSize=50"
);
setPosts(data.posts);
} catch {
/* ignore */
}
}, []);
const openCreate = () => {
setEditingPost(null);
setSheetOpen(true);
};
const openEdit = (post: BlogPost) => {
setEditingPost(post);
setSheetOpen(true);
};
const handleDelete = async (id: string) => {
setDeletingId(id);
try {
await clientFetch(`/blog-posts/${id}`, { method: "DELETE" });
toast.success("Post deleted.");
setPosts((prev) => prev.filter((p) => p.id !== id));
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Delete failed");
} finally {
setDeletingId(null);
}
};
const canEdit = userRole === UserRole.ADMIN;
const canCreate = userRole === UserRole.ADMIN || userRole === UserRole.MANAGER;
const filtered = search.trim()
? posts.filter(
(p) =>
p.title.toLowerCase().includes(search.toLowerCase()) ||
p.slug.toLowerCase().includes(search.toLowerCase())
)
: posts;
return (
<>
{/* ── Toolbar ───────────────────────────────────────────────────────── */}
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search posts…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 h-8 w-56 text-sm"
/>
</div>
{canCreate && (
<Button size="sm" onClick={openCreate} className="gap-1.5">
<PlusCircle className="h-4 w-4" />
New Post
</Button>
)}
</div>
{/* ── Posts list ────────────────────────────────────────────────────── */}
{filtered.length === 0 ? (
<div className="rounded-xl border border-dashed p-10 text-center text-sm text-muted-foreground">
{search
? "No posts match your search."
: "No posts yet. Create your first post!"}
</div>
) : (
<div className="rounded-xl border overflow-hidden divide-y">
{filtered.map((post) => {
const meta = statusMeta[post.status];
return (
<div
key={post.id}
className={`flex items-start gap-3 px-4 py-3.5 border-l-4 bg-card hover:bg-muted/30 transition-colors ${meta.row}`}
>
{/* Thumbnail */}
{post.featuredImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={post.featuredImageUrl}
alt={post.featuredImageAlt || ""}
className="h-12 w-20 rounded-md object-cover shrink-0 border hidden sm:block"
/>
) : (
<div className="h-12 w-20 rounded-md bg-muted shrink-0 hidden sm:flex items-center justify-center">
<span className="text-xs text-muted-foreground/50">No img</span>
</div>
)}
{/* Main content */}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-1.5 mb-0.5">
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${meta.dot}`}
/>
<span className="text-xs text-muted-foreground">
{meta.label}
</span>
{post.isFeatured && (
<Star className="h-3 w-3 text-amber-500 fill-amber-500" />
)}
</div>
<Link
href={`/blog/${post.slug}`}
target="_blank"
className="font-semibold text-sm hover:text-primary transition-colors line-clamp-1"
>
{post.title}
<ExternalLink className="inline-block ml-1 h-2.5 w-2.5 opacity-50" />
</Link>
<div className="flex flex-wrap items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground font-mono">
/{post.slug}
</span>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Eye className="h-3 w-3" /> {post.views.toLocaleString()}
</span>
{post.author && (
<span className="text-xs text-muted-foreground">
{post.author.name || post.author.email}
</span>
)}
</div>
{(post.categories.length > 0 || post.tags.length > 0) && (
<div className="flex flex-wrap gap-1 mt-1.5">
{post.categories.map((c) => (
<Badge
key={c}
variant="secondary"
className="text-xs h-4 px-1.5"
>
{c}
</Badge>
))}
{post.tags.slice(0, 3).map((t) => (
<Badge
key={t}
variant="outline"
className="text-xs h-4 px-1.5"
>
#{t}
</Badge>
))}
{post.tags.length > 3 && (
<span className="text-xs text-muted-foreground">
+{post.tags.length - 3}
</span>
)}
</div>
)}
</div>
{/* Date */}
<span className="text-xs text-muted-foreground shrink-0 hidden md:block">
{new Date(post.updatedAt).toLocaleDateString()}
</span>
{/* Actions */}
{canEdit && (
<div className="flex gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => openEdit(post)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete post?</AlertDialogTitle>
<AlertDialogDescription>
&quot;{post.title}&quot; will be permanently deleted.
This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => handleDelete(post.id)}
disabled={deletingId === post.id}
>
{deletingId === post.id && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</div>
);
})}
</div>
)}
{/* ── Post create/edit sheet ─────────────────────────────────────────── */}
<PostSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
post={editingPost}
userRole={userRole}
onSuccess={refreshPosts}
/>
</>
);
}

View File

@@ -0,0 +1,329 @@
"use client";
import { useState } from "react";
import { clientFetch } from "@/lib/api";
import { toast } from "sonner";
import { User, UserRole } from "@/lib/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Loader2, Pencil, Trash2, Check, Users2 } from "lucide-react";
interface Props {
initialUsers: User[];
}
const roleMeta: Record<UserRole, { label: string; classes: string }> = {
[UserRole.ADMIN]: {
label: "Admin",
classes: "bg-red-100 text-red-800 border-red-200 dark:bg-red-950 dark:text-red-300",
},
[UserRole.MANAGER]: {
label: "Manager",
classes: "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-950 dark:text-amber-300",
},
[UserRole.MEMBER]: {
label: "Member",
classes: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300",
},
};
function Avatar({ name, email }: { name?: string; email: string }) {
const letter = (name || email).charAt(0).toUpperCase();
return (
<div className="h-9 w-9 shrink-0 rounded-full bg-primary/10 flex items-center justify-center text-sm font-semibold text-primary select-none">
{letter}
</div>
);
}
export function UsersTable({ initialUsers }: Props) {
const [users, setUsers] = useState<User[]>(initialUsers);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [deletingUser, setDeletingUser] = useState<User | null>(null);
const [editName, setEditName] = useState("");
const [editRole, setEditRole] = useState<UserRole>(UserRole.MEMBER);
const [loading, setLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const openEdit = (user: User) => {
setEditingUser(user);
setEditName(user.name || "");
setEditRole(user.role);
};
const closeEdit = () => setEditingUser(null);
const handleUpdate = async () => {
if (!editingUser) return;
setLoading(true);
try {
let updated = editingUser;
if (editName.trim() !== (editingUser.name || "")) {
const res = await clientFetch<{ success: boolean; user: User }>(
`/users/${editingUser.id}`,
{
method: "PATCH",
body: JSON.stringify({ name: editName.trim() || undefined }),
}
);
updated = res.user;
}
if (editRole !== editingUser.role) {
const res = await clientFetch<{ success: boolean; user: User }>(
`/users/${editingUser.id}/role`,
{
method: "PATCH",
body: JSON.stringify({ role: editRole }),
}
);
updated = res.user;
}
toast.success("User updated.");
setUsers((prev) =>
prev.map((u) => (u.id === editingUser.id ? updated : u))
);
closeEdit();
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Update failed");
} finally {
setLoading(false);
}
};
const handleDelete = async () => {
if (!deletingUser) return;
setDeleteLoading(true);
try {
await clientFetch(`/users/${deletingUser.id}`, { method: "DELETE" });
toast.success(`User "${deletingUser.email}" deleted.`);
setUsers((prev) => prev.filter((u) => u.id !== deletingUser.id));
setDeletingUser(null);
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Delete failed");
} finally {
setDeleteLoading(false);
}
};
if (users.length === 0) {
return (
<div className="rounded-xl border border-dashed p-10 text-center text-sm text-muted-foreground">
No users found.
</div>
);
}
return (
<>
<div className="rounded-xl border overflow-hidden divide-y">
{users.map((user) => {
const role = roleMeta[user.role];
return (
<div
key={user.id}
className="flex items-center gap-3 px-4 py-3 bg-card hover:bg-muted/30 transition-colors"
>
<Avatar name={user.name} email={user.email} />
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium truncate">
{user.name || (
<span className="italic text-muted-foreground">
No name
</span>
)}
</p>
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${role.classes}`}
>
{role.label}
</span>
{!user.isActive && (
<Badge variant="outline" className="text-xs text-muted-foreground">
Inactive
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground truncate">
{user.email}
</p>
</div>
<span className="text-xs text-muted-foreground shrink-0 hidden sm:block">
{new Date(user.createdAt).toLocaleDateString()}
</span>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
onClick={() => openEdit(user)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => setDeletingUser(user)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
);
})}
</div>
{/* ── Edit sheet ───────────────────────────────────────────────────── */}
<Sheet open={!!editingUser} onOpenChange={(o) => !o && closeEdit()}>
<SheetContent side="right" className="sm:max-w-sm">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<Users2 className="h-4 w-4 text-muted-foreground" />
Edit User
</SheetTitle>
<SheetDescription>{editingUser?.email}</SheetDescription>
</SheetHeader>
<div className="mt-6 space-y-4 px-1">
{/* Avatar preview */}
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center text-lg font-semibold text-primary">
{(editName || editingUser?.email || "?").charAt(0).toUpperCase()}
</div>
<div>
<p className="text-sm font-medium">{editName || "No name"}</p>
<p className="text-xs text-muted-foreground">{editingUser?.email}</p>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="eu-name">Display name</Label>
<Input
id="eu-name"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Full name"
/>
</div>
<div className="space-y-1.5">
<Label>Role</Label>
<Select
value={editRole}
onValueChange={(v) => setEditRole(v as UserRole)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={UserRole.MEMBER}>
<span className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-blue-500" />
Member
</span>
</SelectItem>
<SelectItem value={UserRole.MANAGER}>
<span className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-amber-500" />
Manager
</span>
</SelectItem>
<SelectItem value={UserRole.ADMIN}>
<span className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-red-500" />
Admin
</span>
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{editRole === UserRole.ADMIN
? "Full access: create, edit, delete posts and manage users."
: editRole === UserRole.MANAGER
? "Can create posts (saved as draft for review)."
: "Read-only access to the dashboard."}
</p>
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" className="flex-1" onClick={closeEdit} disabled={loading}>
Cancel
</Button>
<Button className="flex-1" onClick={handleUpdate} disabled={loading}>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Check className="mr-2 h-4 w-4" />
)}
Save
</Button>
</div>
</div>
</SheetContent>
</Sheet>
{/* ── Delete confirmation dialog ────────────────────────────────────── */}
<AlertDialog open={!!deletingUser} onOpenChange={(o) => !o && setDeletingUser(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete user?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete{" "}
<span className="font-medium text-foreground">
{deletingUser?.name || deletingUser?.email}
</span>
. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteLoading}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={deleteLoading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,243 @@
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { apiFetch, ApiError } from "@/lib/api";
import { BlogPost, User, UserRole, CurrentUser, PostStatus } from "@/lib/types";
import { PostsTable } from "./components/posts-table";
import { UsersTable } from "./components/users-table";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
FileText,
Users,
Eye,
TrendingUp,
CheckCircle2,
Clock,
Archive,
} from "lucide-react";
// ── JWT decode (no verification — display only) ────────────────────────────
function decodeJwtPayload(token: string): CurrentUser | null {
try {
const base64Payload = token.split(".")[1];
const decoded = Buffer.from(base64Payload, "base64url").toString("utf-8");
return JSON.parse(decoded) as CurrentUser;
} catch {
return null;
}
}
const rolePill: Record<UserRole, string> = {
[UserRole.ADMIN]: "bg-red-100 text-red-700 border-red-200",
[UserRole.MANAGER]: "bg-amber-100 text-amber-700 border-amber-200",
[UserRole.MEMBER]: "bg-blue-100 text-blue-700 border-blue-200",
};
// ── Stat card ──────────────────────────────────────────────────────────────
function StatCard({
icon: Icon,
label,
value,
sub,
accent,
}: {
icon: React.ElementType;
label: string;
value: number | string;
sub?: string;
accent?: string;
}) {
return (
<Card className="overflow-hidden">
<CardContent className="p-5">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
{label}
</p>
<p className={`text-3xl font-bold tracking-tight ${accent ?? ""}`}>
{value}
</p>
{sub && (
<p className="text-xs text-muted-foreground mt-1">{sub}</p>
)}
</div>
<div className="h-9 w-9 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Icon className="h-4.5 w-4.5 text-muted-foreground" />
</div>
</div>
</CardContent>
</Card>
);
}
// ── Page ───────────────────────────────────────────────────────────────────
export default async function DashboardPage() {
/* Auth */
const cookieStore = await cookies();
const accessToken = cookieStore.get("accessToken")?.value;
if (!accessToken) redirect("/auth");
const currentUser = decodeJwtPayload(accessToken);
if (!currentUser) redirect("/auth");
/* Posts */
let posts: BlogPost[] = [];
try {
const data = await apiFetch<{ posts: BlogPost[]; total: number }>(
"/blog-posts?pageSize=100"
);
posts = data.posts;
} catch (err) {
if (err instanceof ApiError && err.status === 401) redirect("/auth");
}
/* Users (ADMIN only) */
let users: User[] = [];
if (currentUser.role === UserRole.ADMIN) {
try {
const data = await apiFetch<{ users: User[]; total: number }>(
"/users?pageSize=100"
);
users = data.users;
} catch { /* non-fatal */ }
}
/* Stats */
const published = posts.filter((p) => p.status === PostStatus.PUBLISHED).length;
const drafts = posts.filter((p) => p.status === PostStatus.DRAFT).length;
const archived = posts.filter((p) => p.status === PostStatus.ARCHIVED).length;
const totalViews = posts.reduce((s, p) => s + (p.views ?? 0), 0);
const displayName = currentUser.name || currentUser.email;
const isAdmin = currentUser.role === UserRole.ADMIN;
return (
<div className="min-h-screen bg-muted/20">
{/* ── Top header bar ──────────────────────────────────────────────────── */}
<div className="border-b bg-background">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold">Dashboard</h1>
<p className="text-xs text-muted-foreground">
Welcome back, <span className="font-medium text-foreground">{displayName}</span>
</p>
</div>
<span
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold ${rolePill[currentUser.role as UserRole]}`}
>
{currentUser.role}
</span>
</div>
</div>
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6 space-y-6">
{/* ── Stats grid ────────────────────────────────────────────────────── */}
<div className={`grid gap-4 ${isAdmin ? "sm:grid-cols-2 lg:grid-cols-4" : "sm:grid-cols-3"}`}>
<StatCard
icon={FileText}
label="Total posts"
value={posts.length}
sub={`${published} published`}
/>
<StatCard
icon={CheckCircle2}
label="Published"
value={published}
accent="text-emerald-600"
/>
<StatCard
icon={Clock}
label="Drafts"
value={drafts}
accent="text-zinc-500"
/>
{isAdmin && (
<>
<StatCard
icon={Eye}
label="Total views"
value={totalViews.toLocaleString()}
sub="across all posts"
accent="text-primary"
/>
</>
)}
</div>
{/* Second row for admin — archived + users */}
{isAdmin && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard
icon={Archive}
label="Archived"
value={archived}
accent="text-amber-600"
/>
<StatCard
icon={Users}
label="Total users"
value={users.length}
sub={`${users.filter((u) => u.isActive).length} active`}
/>
<StatCard
icon={TrendingUp}
label="Avg. views / post"
value={posts.length ? Math.round(totalViews / posts.length).toLocaleString() : "—"}
/>
</div>
)}
{/* ── Tabs ──────────────────────────────────────────────────────────── */}
<Tabs defaultValue="posts" className="space-y-4">
<TabsList className={isAdmin ? "" : "w-auto"}>
<TabsTrigger value="posts" className="gap-1.5">
<FileText className="h-3.5 w-3.5" />
Posts
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground ml-0.5">
{posts.length}
</span>
</TabsTrigger>
{isAdmin && (
<TabsTrigger value="users" className="gap-1.5">
<Users className="h-3.5 w-3.5" />
Users
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground ml-0.5">
{users.length}
</span>
</TabsTrigger>
)}
</TabsList>
{/* Posts tab */}
<TabsContent value="posts" className="mt-0">
<PostsTable
initialPosts={posts}
userRole={currentUser.role as UserRole}
/>
</TabsContent>
{/* Users tab (admin only) */}
{isAdmin && (
<TabsContent value="users" className="mt-0">
<UsersTable initialUsers={users} />
</TabsContent>
)}
</Tabs>
{/* Member read-only notice */}
{currentUser.role === UserRole.MEMBER && (
<Card className="border-dashed border-muted-foreground/30 bg-muted/20">
<CardContent className="py-4 px-5">
<p className="text-sm text-muted-foreground">
<span className="font-semibold text-foreground">Read-only access.</span>{" "}
You can view posts but cannot create, edit, or delete them. Contact an admin to upgrade your role.
</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}

BIN
frontend/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

239
frontend/app/globals.css Normal file
View File

@@ -0,0 +1,239 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1.0000 0 0);
--foreground: oklch(0 0 0);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0 0 0);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0 0 0);
--primary: oklch(0.5323 0.1730 141.3064);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.5762 0.2175 258.9127);
--secondary-foreground: oklch(1.0000 0 0);
--muted: oklch(0.9551 0 0);
--muted-foreground: oklch(0.3211 0 0);
--accent: oklch(0.5635 0.2408 260.8178);
--accent-foreground: oklch(1.0000 0 0);
--destructive: oklch(0 0 0);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0 0 0);
--input: oklch(0 0 0);
--ring: oklch(0.6489 0.2370 26.9728);
--chart-1: oklch(0.6489 0.2370 26.9728);
--chart-2: oklch(0.9680 0.2110 109.7692);
--chart-3: oklch(0.5635 0.2408 260.8178);
--chart-4: oklch(0.7323 0.2492 142.4953);
--chart-5: oklch(0.5931 0.2726 328.3634);
--sidebar: oklch(0.9551 0 0);
--sidebar-foreground: oklch(0 0 0);
--sidebar-primary: oklch(0.6489 0.2370 26.9728);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.5635 0.2408 260.8178);
--sidebar-accent-foreground: oklch(1.0000 0 0);
--sidebar-border: oklch(0 0 0);
--sidebar-ring: oklch(0.6489 0.2370 26.9728);
--font-sans: DM Sans, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: Space Mono, monospace;
--radius: 0px;
--shadow-x: 4px;
--shadow-y: 4px;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-opacity: 1;
--shadow-color: hsl(0 0% 0%);
--shadow-2xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-sm: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow-md: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 2px 4px -1px hsl(0 0% 0% / 1.00);
--shadow-lg: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 4px 6px -1px hsl(0 0% 0% / 1.00);
--shadow-xl: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 8px 10px -1px hsl(0 0% 0% / 1.00);
--shadow-2xl: 4px 4px 0px 0px hsl(0 0% 0% / 2.50);
--tracking-normal: 0em;
--spacing: 0.25rem;
}
.dark {
--background: oklch(0 0 0);
--foreground: oklch(1.0000 0 0);
--card: oklch(0.3211 0 0);
--card-foreground: oklch(1.0000 0 0);
--popover: oklch(0.3211 0 0);
--popover-foreground: oklch(1.0000 0 0);
--primary: oklch(0.7044 0.1872 23.1858);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.9691 0.2005 109.6228);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.2178 0 0);
--muted-foreground: oklch(0.8452 0 0);
--accent: oklch(0.6755 0.1765 252.2592);
--accent-foreground: oklch(0 0 0);
--destructive: oklch(1.0000 0 0);
--destructive-foreground: oklch(0 0 0);
--border: oklch(1.0000 0 0);
--input: oklch(1.0000 0 0);
--ring: oklch(0.7044 0.1872 23.1858);
--chart-1: oklch(0.7044 0.1872 23.1858);
--chart-2: oklch(0.9691 0.2005 109.6228);
--chart-3: oklch(0.6755 0.1765 252.2592);
--chart-4: oklch(0.7395 0.2268 142.8504);
--chart-5: oklch(0.6131 0.2458 328.0714);
--sidebar: oklch(0 0 0);
--sidebar-foreground: oklch(1.0000 0 0);
--sidebar-primary: oklch(0.7044 0.1872 23.1858);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.6755 0.1765 252.2592);
--sidebar-accent-foreground: oklch(0 0 0);
--sidebar-border: oklch(1.0000 0 0);
--sidebar-ring: oklch(0.7044 0.1872 23.1858);
--font-sans: DM Sans, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: Space Mono, monospace;
--radius: 0px;
--shadow-x: 4px;
--shadow-y: 4px;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-opacity: 1;
--shadow-color: hsl(0 0% 0%);
--shadow-2xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-sm: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow-md: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 2px 4px -1px hsl(0 0% 0% / 1.00);
--shadow-lg: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 4px 6px -1px hsl(0 0% 0% / 1.00);
--shadow-xl: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 8px 10px -1px hsl(0 0% 0% / 1.00);
--shadow-2xl: 4px 4px 0px 0px hsl(0 0% 0% / 2.50);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
/* ── Tiptap editor content styles ─────────────────────────────────────────── */
.tiptap-content .ProseMirror {
outline: none;
min-height: inherit;
}
.tiptap-content .ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: oklch(0.556 0 0);
pointer-events: none;
height: 0;
}
.tiptap-content .ProseMirror > * + * { margin-top: 0.65em; }
.tiptap-content .ProseMirror h1 { font-size: 1.6rem; font-weight: 700; line-height: 1.2; }
.tiptap-content .ProseMirror h2 { font-size: 1.3rem; font-weight: 700; line-height: 1.3; }
.tiptap-content .ProseMirror h3 { font-size: 1.1rem; font-weight: 600; line-height: 1.4; }
.tiptap-content .ProseMirror strong { font-weight: 700; }
.tiptap-content .ProseMirror em { font-style: italic; }
.tiptap-content .ProseMirror u { text-decoration: underline; }
.tiptap-content .ProseMirror s { text-decoration: line-through; }
.tiptap-content .ProseMirror mark { background-color: oklch(0.97 0.18 95); border-radius: 0.15em; padding: 0 0.15em; }
.tiptap-content .ProseMirror ul { list-style-type: disc; padding-left: 1.4em; }
.tiptap-content .ProseMirror ol { list-style-type: decimal; padding-left: 1.4em; }
.tiptap-content .ProseMirror li > p { margin: 0; }
.tiptap-content .ProseMirror blockquote {
border-left: 3px solid oklch(0.708 0 0);
margin-left: 0;
padding-left: 1em;
color: oklch(0.556 0 0);
font-style: italic;
}
.tiptap-content .ProseMirror code {
background: oklch(0.97 0 0);
border-radius: 0.25em;
padding: 0.1em 0.35em;
font-size: 0.875em;
font-family: var(--font-mono, monospace);
}
.tiptap-content .ProseMirror pre {
background: oklch(0.145 0 0);
color: oklch(0.922 0 0);
border-radius: 0.5em;
padding: 0.85em 1.1em;
overflow-x: auto;
}
.tiptap-content .ProseMirror pre code {
background: none;
padding: 0;
font-size: 0.85em;
color: inherit;
}
.tiptap-content .ProseMirror hr {
border: none;
border-top: 2px solid oklch(0.922 0 0);
margin: 1.25em 0;
}
.dark .tiptap-content .ProseMirror p.is-editor-empty:first-child::before { color: oklch(0.708 0 0); }
.dark .tiptap-content .ProseMirror mark { background-color: oklch(0.75 0.15 90 / 0.35); }
.dark .tiptap-content .ProseMirror code { background: oklch(0.269 0 0); }
.dark .tiptap-content .ProseMirror pre { background: oklch(0.1 0 0); }
.dark .tiptap-content .ProseMirror blockquote { border-left-color: oklch(0.556 0 0); color: oklch(0.708 0 0); }
.dark .tiptap-content .ProseMirror hr { border-top-color: oklch(1 0 0 / 10%); }

47
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,47 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/lib/auth";
import { Navbar } from "@/components/navbar";
import { Toaster } from "@/components/ui/sonner";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: {
default: "Duc Binh Blog",
template: "%s | Duc Binh Blog",
},
description:
"Practical engineering stories — fast backend patterns, frontend craft, and product lessons.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen bg-background`}
>
<AuthProvider>
<Navbar />
<main className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
{children}
</main>
<Toaster position="top-right" richColors />
</AuthProvider>
</body>
</html>
);
}

332
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,332 @@
import { Suspense } from "react";
import Link from "next/link";
import Image from "next/image";
import { apiFetch } from "@/lib/api";
import { BlogPost, PaginatedResult, TagCloudItem } from "@/lib/types";
import { PostCard } from "@/components/post-card";
import { Pagination } from "@/components/pagination";
import { PostFilterForm } from "@/components/post-filter-form";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Eye, Star, TrendingUp } from "lucide-react";
interface HomeSearchParams {
q?: string;
tags?: string;
category?: string;
sort?: string;
page?: string;
pageSize?: string;
}
async function getFeatured(): Promise<BlogPost[]> {
try {
return await apiFetch<BlogPost[]>("/blog-posts/public/featured");
} catch {
return [];
}
}
async function getPosts(
params: HomeSearchParams
): Promise<PaginatedResult<BlogPost>> {
const qs = new URLSearchParams();
if (params.q) qs.set("q", params.q);
if (params.tags) qs.set("tags", params.tags);
if (params.category) qs.set("category", params.category);
if (params.sort) qs.set("sort", params.sort);
qs.set("page", params.page || "1");
qs.set("pageSize", params.pageSize || "9");
try {
return await apiFetch<PaginatedResult<BlogPost>>(
`/blog-posts/public?${qs.toString()}`
);
} catch {
return { items: [], total: 0, page: 1, pageSize: 9, totalPages: 0 };
}
}
async function getAllPostsForCloud(): Promise<BlogPost[]> {
try {
const result = await apiFetch<PaginatedResult<BlogPost>>(
"/blog-posts/public?pageSize=100"
);
return result.items;
} catch {
return [];
}
}
function buildTagCloud(posts: BlogPost[]): TagCloudItem[] {
const counts: Record<string, number> = {};
for (const post of posts) {
for (const tag of post.tags || []) {
const t = tag.trim();
if (t) counts[t] = (counts[t] || 0) + 1;
}
}
return Object.entries(counts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 20);
}
function buildCategoryCloud(posts: BlogPost[]): TagCloudItem[] {
const counts: Record<string, number> = {};
for (const post of posts) {
for (const cat of post.categories || []) {
const c = cat.trim();
if (c) counts[c] = (counts[c] || 0) + 1;
}
}
return Object.entries(counts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 10);
}
export default async function HomePage({
searchParams,
}: {
searchParams: Promise<HomeSearchParams>;
}) {
const params = await searchParams;
const [featured, postsResult, allPosts, popularResult] = await Promise.all([
getFeatured(),
getPosts(params),
getAllPostsForCloud(),
apiFetch<PaginatedResult<BlogPost>>(
"/blog-posts/public?sort=most_viewed&pageSize=5"
).catch(() => ({ items: [] as BlogPost[] })),
]);
const topTags = buildTagCloud(allPosts);
const topCategories = buildCategoryCloud(allPosts);
const popularPosts = (popularResult as PaginatedResult<BlogPost>).items || [];
const { items: posts, page, totalPages, total } = postsResult;
return (
<div className="space-y-6">
{/* ── Hero ──────────────────────────────────────────────────────────── */}
<section className="rounded-2xl border bg-gradient-to-r from-background via-muted/30 to-background p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="max-w-2xl">
<h1 className="text-3xl font-bold tracking-tight">
The blog is all about web dev tech
</h1>
<p className="mt-2 text-muted-foreground">
Fast backend patterns, frontend craft, and product lessons from
shipping real systems.
</p>
</div>
<div className="flex flex-wrap gap-2">
{topCategories.slice(0, 6).map((c) => (
<Link
key={c.name}
href={`/?category=${encodeURIComponent(c.name)}`}
>
<Badge
variant="secondary"
className="cursor-pointer hover:bg-secondary/80"
>
{c.name} ({c.count})
</Badge>
</Link>
))}
</div>
</div>
</section>
{/* ── Featured posts ─────────────────────────────────────────────────── */}
{featured.length > 0 && (
<section className="rounded-2xl border bg-gradient-to-r from-background via-sky-50 to-emerald-50 dark:via-sky-950/20 dark:to-emerald-950/20 p-5">
<div className="flex items-center gap-2 mb-4">
<Star className="h-4 w-4 text-amber-500 fill-amber-500" />
<h2 className="font-semibold text-lg">Featured</h2>
</div>
<div className="grid gap-4 lg:grid-cols-[1.5fr_1fr]">
{/* Primary featured */}
<article className="rounded-xl border bg-background/80 p-5">
<Badge className="mb-3 bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200 border-amber-200">
Featured pick
</Badge>
{featured[0].featuredImageUrl && (
<Link href={`/blog/${featured[0].slug}`} className="block mb-4">
<div className="relative h-52 w-full overflow-hidden rounded-lg">
<Image
src={featured[0].featuredImageUrl}
alt={featured[0].featuredImageAlt || featured[0].title}
fill
className="object-cover"
sizes="(max-width: 1024px) 100vw, 60vw"
priority
/>
</div>
</Link>
)}
<Link href={`/blog/${featured[0].slug}`} className="group">
<h2 className="text-2xl font-bold leading-tight group-hover:text-primary transition-colors">
{featured[0].title}
</h2>
</Link>
<p className="mt-2 text-muted-foreground">
{featured[0].excerpt}
</p>
<p className="mt-2 text-sm text-muted-foreground flex items-center gap-1">
<Eye className="h-3 w-3" />
{featured[0].views.toLocaleString()} views
</p>
</article>
{/* Secondary featured */}
<div className="grid gap-3">
{featured.slice(1).map((post) => (
<article
key={post.id}
className="rounded-xl border bg-background/80 p-4 flex gap-3"
>
{post.featuredImageUrl && (
<div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-lg">
<Image
src={post.featuredImageUrl}
alt={post.featuredImageAlt || post.title}
fill
className="object-cover"
sizes="64px"
/>
</div>
)}
<div className="min-w-0">
<Link href={`/blog/${post.slug}`} className="group">
<h3 className="font-semibold leading-tight group-hover:text-primary transition-colors line-clamp-2">
{post.title}
</h3>
</Link>
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
{post.excerpt}
</p>
</div>
</article>
))}
</div>
</div>
</section>
)}
{/* ── Main content + sidebar ─────────────────────────────────────────── */}
<div className="grid gap-6 lg:grid-cols-[1fr_20rem]">
<div className="space-y-4">
{/* Filter form */}
<Suspense fallback={null}>
<PostFilterForm
topCategories={topCategories}
currentQ={params.q || ""}
currentCategory={params.category || ""}
currentSort={params.sort || "newest"}
currentTags={params.tags || ""}
/>
</Suspense>
{/* Post grid */}
{posts.length === 0 ? (
<div className="rounded-xl border bg-muted/30 p-8 text-center">
<p className="text-muted-foreground">
No posts found for this filter.
</p>
<Button asChild variant="link" className="mt-2">
<Link href="/">Clear filters</Link>
</Button>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
)}
{/* Pagination */}
<Suspense fallback={null}>
<Pagination page={page} totalPages={totalPages} total={total} />
</Suspense>
</div>
{/* Sidebar */}
<aside className="space-y-4">
{/* Popular Posts */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<TrendingUp className="h-4 w-4" /> Popular Posts
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{popularPosts.map((post) => (
<Link
key={post.slug}
href={`/blog/${post.slug}`}
className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm font-medium hover:bg-muted transition-colors"
>
<span className="line-clamp-1 mr-2">{post.title}</span>
<span className="text-muted-foreground shrink-0 flex items-center gap-1">
<Eye className="h-3 w-3" />
{post.views.toLocaleString()}
</span>
</Link>
))}
</CardContent>
</Card>
{/* Newsletter */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Newsletter</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-3">
One practical note every Friday.
</p>
<div className="flex flex-col gap-2">
<Input type="email" placeholder="you@example.com" />
<Button className="w-full">Join</Button>
</div>
</CardContent>
</Card>
{/* Tag Cloud */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Tag Cloud</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{topTags.map((tag) => (
<Link
key={tag.name}
href={`/?tags=${encodeURIComponent(tag.name)}`}
>
<Badge
variant="outline"
className="cursor-pointer hover:bg-muted transition-colors"
>
#{tag.name} ({tag.count})
</Badge>
</Link>
))}
</div>
</CardContent>
</Card>
<Separator />
<p className="text-xs text-center text-muted-foreground">
Built with NestJS + Next.js 16 + shadcn/ui
</p>
</aside>
</div>
</div>
);
}

23
frontend/components.json Normal file
View File

@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -0,0 +1,203 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Loader2, Upload, X, ImageIcon } from "lucide-react";
import type { UploadImageResponse } from "@/app/api/upload-image/route";
interface Props {
/** Current image URL (controlled) */
value?: string;
/** Called when a new image URL is ready (after upload) or cleared (empty string) */
onChange: (url: string) => void;
/** Alt text value */
altValue?: string;
/** Called when alt text changes */
onAltChange?: (alt: string) => void;
/** Label shown above the dropzone */
label?: string;
}
const ACCEPTED = "image/jpeg,image/png,image/gif,image/webp,image/svg+xml,image/avif";
export function ImageUploader({
value,
onChange,
altValue = "",
onAltChange,
label = "Featured Image",
}: Props) {
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const uploadFile = useCallback(
async (file: File) => {
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/upload-image", {
method: "POST",
body: fd,
});
const data = (await res.json()) as UploadImageResponse & { error?: string };
if (!res.ok || !data.success) {
throw new Error(data.error || "Upload failed");
}
onChange(data.url);
toast.success("Image uploaded successfully!");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Upload failed");
} finally {
setUploading(false);
}
},
[onChange]
);
// ── Drag & Drop handlers ───────────────────────────────────────────────────
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
};
const handleDragLeave = () => setDragOver(false);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file) uploadFile(file);
};
// ── File input change ──────────────────────────────────────────────────────
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
uploadFile(file);
// Reset so the same file can be re-selected if removed
e.target.value = "";
}
};
const handleRemove = () => {
onChange("");
onAltChange?.("");
};
return (
<div className="space-y-2">
<Label>{label}</Label>
{value ? (
/* ── Preview ──────────────────────────────────────────────────────── */
<div className="relative rounded-xl overflow-hidden border bg-muted">
{/* Plain <img> — domain is dynamic (R2/custom), avoids next/image remotePatterns config */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={value}
alt={altValue || "Featured image preview"}
className="aspect-video w-full object-cover"
/>
{/* Overlay — Replace / Remove actions */}
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/40 opacity-0 hover:opacity-100 transition-opacity">
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
<span className="ml-1.5">Replace</span>
</Button>
<Button
type="button"
size="sm"
variant="destructive"
onClick={handleRemove}
disabled={uploading}
>
<X className="h-4 w-4" />
<span className="ml-1.5">Remove</span>
</Button>
</div>
{uploading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<Loader2 className="h-8 w-8 animate-spin text-white" />
</div>
)}
</div>
) : (
/* ── Dropzone ─────────────────────────────────────────────────────── */
<button
type="button"
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
disabled={uploading}
className={`
w-full rounded-xl border-2 border-dashed p-8
flex flex-col items-center justify-center gap-2
text-sm text-muted-foreground transition-colors cursor-pointer
hover:border-primary/50 hover:bg-muted/40
focus:outline-none focus-visible:ring-2 focus-visible:ring-ring
disabled:cursor-not-allowed disabled:opacity-60
${dragOver ? "border-primary bg-primary/5" : "border-border"}
`}
>
{uploading ? (
<>
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span>Uploading</span>
</>
) : (
<>
<ImageIcon className="h-8 w-8 opacity-40" />
<span className="font-medium">
{dragOver ? "Drop to upload" : "Click or drag an image here"}
</span>
<span className="text-xs opacity-70">
JPEG · PNG · GIF · WebP · SVG · AVIF max 10 MB
</span>
</>
)}
</button>
)}
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED}
className="hidden"
onChange={handleFileChange}
/>
{/* Alt text field — always shown */}
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Image alt text</Label>
<Input
placeholder="Describe the image for accessibility…"
value={altValue}
onChange={(e) => onAltChange?.(e.target.value)}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,243 @@
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import Underline from "@tiptap/extension-underline";
import Highlight from "@tiptap/extension-highlight";
import TextAlign from "@tiptap/extension-text-align";
import Link from "@tiptap/extension-link";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
Bold,
Italic,
UnderlineIcon,
Strikethrough,
Highlighter,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Code,
Code2,
AlignLeft,
AlignCenter,
AlignRight,
Minus,
Undo2,
Redo2,
Link2,
Link2Off,
} from "lucide-react";
import { useCallback, useEffect } from "react";
interface Props {
value?: string;
onChange: (html: string) => void;
placeholder?: string;
minHeight?: string;
}
export function TiptapEditor({
value = "",
onChange,
placeholder = "Write your post content here…",
minHeight = "320px",
}: Props) {
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
bulletList: { keepMarks: true, keepAttributes: false },
orderedList: { keepMarks: true, keepAttributes: false },
}),
Underline,
Highlight.configure({ multicolor: false }),
TextAlign.configure({ types: ["heading", "paragraph"] }),
Placeholder.configure({ placeholder }),
Link.configure({
openOnClick: false,
HTMLAttributes: { class: "text-primary underline underline-offset-2" },
}),
],
content: value,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
});
// Sync external value changes (e.g. when edit form opens with existing content)
useEffect(() => {
if (!editor) return;
const current = editor.getHTML();
if (value !== current) {
editor.commands.setContent(value || "", { emitUpdate: false });
}
}, [value, editor]);
const setLink = useCallback(() => {
if (!editor) return;
const prev = editor.getAttributes("link").href as string | undefined;
const url = window.prompt("URL", prev ?? "https://");
if (url === null) return; // cancelled
if (url === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
} else {
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
}
}, [editor]);
if (!editor) return null;
const btn = (active: boolean) =>
`h-7 w-7 p-0 ${active ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted/60"}`;
return (
<div className="rounded-xl border bg-background overflow-hidden">
{/* ── Toolbar ─────────────────────────────────────────────────────────── */}
<div className="flex flex-wrap items-center gap-0.5 border-b bg-muted/30 p-1.5">
{/* History */}
<Button type="button" variant="ghost" size="sm" className={btn(false)}
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}>
<Undo2 className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm" className={btn(false)}
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}>
<Redo2 className="h-3.5 w-3.5" />
</Button>
<Separator orientation="vertical" className="mx-1 h-5" />
{/* Headings */}
<Button type="button" variant="ghost" size="sm"
className={btn(editor.isActive("heading", { level: 1 }))}
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}>
<Heading1 className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm"
className={btn(editor.isActive("heading", { level: 2 }))}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>
<Heading2 className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm"
className={btn(editor.isActive("heading", { level: 3 }))}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>
<Heading3 className="h-3.5 w-3.5" />
</Button>
<Separator orientation="vertical" className="mx-1 h-5" />
{/* Inline marks */}
<Button type="button" variant="ghost" size="sm"
className={btn(editor.isActive("bold"))}
onClick={() => editor.chain().focus().toggleBold().run()}>
<Bold className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm"
className={btn(editor.isActive("italic"))}
onClick={() => editor.chain().focus().toggleItalic().run()}>
<Italic className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm"
className={btn(editor.isActive("underline"))}
onClick={() => editor.chain().focus().toggleUnderline().run()}>
<UnderlineIcon className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm"
className={btn(editor.isActive("strike"))}
onClick={() => editor.chain().focus().toggleStrike().run()}>
<Strikethrough className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm"
className={btn(editor.isActive("highlight"))}
onClick={() => editor.chain().focus().toggleHighlight().run()}>
<Highlighter className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm"
className={btn(editor.isActive("code"))}
onClick={() => editor.chain().focus().toggleCode().run()}>
<Code className="h-3.5 w-3.5" />
</Button>
<Separator orientation="vertical" className="mx-1 h-5" />
{/* Link */}
<Button type="button" variant="ghost" size="sm"
className={btn(editor.isActive("link"))}
onClick={setLink}>
<Link2 className="h-3.5 w-3.5" />
</Button>
{editor.isActive("link") && (
<Button type="button" variant="ghost" size="sm"
className={btn(false)}
onClick={() => editor.chain().focus().unsetLink().run()}>
<Link2Off className="h-3.5 w-3.5" />
</Button>
)}
<Separator orientation="vertical" className="mx-1 h-5" />
{/* Lists */}
<Button type="button" variant="ghost" size="sm"
className={btn(editor.isActive("bulletList"))}
onClick={() => editor.chain().focus().toggleBulletList().run()}>
<List className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm"
className={btn(editor.isActive("orderedList"))}
onClick={() => editor.chain().focus().toggleOrderedList().run()}>
<ListOrdered className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm"
className={btn(editor.isActive("blockquote"))}
onClick={() => editor.chain().focus().toggleBlockquote().run()}>
<Quote className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm"
className={btn(editor.isActive("codeBlock"))}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}>
<Code2 className="h-3.5 w-3.5" />
</Button>
<Separator orientation="vertical" className="mx-1 h-5" />
{/* Alignment */}
<Button type="button" variant="ghost" size="sm"
className={btn(editor.isActive({ textAlign: "left" }))}
onClick={() => editor.chain().focus().setTextAlign("left").run()}>
<AlignLeft className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm"
className={btn(editor.isActive({ textAlign: "center" }))}
onClick={() => editor.chain().focus().setTextAlign("center").run()}>
<AlignCenter className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm"
className={btn(editor.isActive({ textAlign: "right" }))}
onClick={() => editor.chain().focus().setTextAlign("right").run()}>
<AlignRight className="h-3.5 w-3.5" />
</Button>
<Separator orientation="vertical" className="mx-1 h-5" />
{/* Divider */}
<Button type="button" variant="ghost" size="sm" className={btn(false)}
onClick={() => editor.chain().focus().setHorizontalRule().run()}>
<Minus className="h-3.5 w-3.5" />
</Button>
</div>
{/* ── Editor area ─────────────────────────────────────────────────────── */}
<EditorContent
editor={editor}
className="tiptap-content px-4 py-3 text-sm focus-within:outline-none"
style={{ minHeight }}
/>
</div>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import Link from "next/link";
import { useAuth } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { BookOpen, LayoutDashboard, LogOut, LogIn } from "lucide-react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
export function Navbar() {
const { currentUser, loading, logout } = useAuth();
const router = useRouter();
const handleLogout = async () => {
await logout();
toast.success("Logged out successfully");
router.push("/");
router.refresh();
};
return (
<header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
{/* Logo */}
<Link
href="/"
className="flex items-center gap-2 font-semibold text-foreground hover:text-primary transition-colors"
>
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground">
DB
</span>
<span className="hidden sm:inline">Duc Binh Blog</span>
</Link>
{/* Nav */}
<nav className="flex items-center gap-2">
<Button variant="ghost" size="sm" asChild>
<Link href="/">
<BookOpen className="h-4 w-4 mr-1" />
<span className="hidden sm:inline">Home</span>
</Link>
</Button>
{!loading && (
<>
{currentUser ? (
<>
<Button variant="ghost" size="sm" asChild>
<Link href="/dashboard">
<LayoutDashboard className="h-4 w-4 mr-1" />
<span className="hidden sm:inline">Dashboard</span>
</Link>
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
className="text-destructive hover:text-destructive"
>
<LogOut className="h-4 w-4 mr-1" />
<span className="hidden sm:inline">Logout</span>
</Button>
</>
) : (
<Button variant="ghost" size="sm" asChild>
<Link href="/auth">
<LogIn className="h-4 w-4 mr-1" />
<span className="hidden sm:inline">Sign In</span>
</Link>
</Button>
)}
</>
)}
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
interface PaginationProps {
page: number;
totalPages: number;
total: number;
}
export function Pagination({ page, totalPages, total }: PaginationProps) {
const router = useRouter();
const searchParams = useSearchParams();
if (totalPages <= 1) return null;
const goToPage = (p: number) => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", String(p));
router.push(`?${params.toString()}`);
};
// Show at most 5 page numbers
const getPageNumbers = () => {
const pages: number[] = [];
const start = Math.max(1, page - 2);
const end = Math.min(totalPages, start + 4);
for (let i = start; i <= end; i++) pages.push(i);
return pages;
};
return (
<div className="flex items-center justify-between gap-2 pt-4 border-t">
<p className="text-sm text-muted-foreground">
Page {page} of {totalPages} ({total} posts)
</p>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => goToPage(page - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
{getPageNumbers().map((p) => (
<Button
key={p}
variant={p === page ? "default" : "outline"}
size="sm"
onClick={() => goToPage(p)}
className="w-9"
>
{p}
</Button>
))}
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => goToPage(page + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import Link from "next/link";
import Image from "next/image";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Eye } from "lucide-react";
import { BlogPost } from "@/lib/types";
interface PostCardProps {
post: BlogPost;
}
export function PostCard({ post }: PostCardProps) {
return (
<Card className="flex flex-col overflow-hidden hover:shadow-md transition-shadow h-full">
{post.featuredImageUrl && (
<Link href={`/blog/${post.slug}`} className="block overflow-hidden">
<div className="relative h-48 w-full">
<Image
src={post.featuredImageUrl}
alt={post.featuredImageAlt || post.title}
fill
className="object-cover transition-transform duration-300 hover:scale-[1.02]"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
</div>
</Link>
)}
<CardContent className="flex-1 pt-4 pb-2">
{post.categories.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1">
{post.categories.slice(0, 3).map((cat) => (
<Badge
key={cat}
variant="secondary"
className="text-xs uppercase tracking-wide"
>
{cat}
</Badge>
))}
</div>
)}
<Link href={`/blog/${post.slug}`} className="group">
<h3 className="font-semibold leading-tight text-foreground group-hover:text-primary transition-colors line-clamp-2">
{post.title}
</h3>
</Link>
{post.excerpt && (
<p className="mt-2 text-sm text-muted-foreground line-clamp-3">
{post.excerpt}
</p>
)}
</CardContent>
<CardFooter className="pt-0 pb-4 px-6 flex flex-wrap gap-1.5 items-center justify-between">
<div className="flex flex-wrap gap-1">
{post.tags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
#{tag}
</Badge>
))}
</div>
<span className="flex items-center gap-1 text-xs text-muted-foreground ml-auto">
<Eye className="h-3 w-3" />
{post.views.toLocaleString()}
</span>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,137 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Search, X } from "lucide-react";
import { TagCloudItem } from "@/lib/types";
interface PostFilterFormProps {
topCategories: TagCloudItem[];
currentQ: string;
currentCategory: string;
currentSort: string;
currentTags: string;
}
export function PostFilterForm({
topCategories,
currentQ,
currentCategory,
currentSort,
currentTags,
}: PostFilterFormProps) {
const router = useRouter();
const searchParams = useSearchParams();
const updateParam = useCallback(
(updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, val] of Object.entries(updates)) {
if (val) {
params.set(key, val);
} else {
params.delete(key);
}
}
params.set("page", "1");
router.push(`?${params.toString()}`);
},
[router, searchParams]
);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
updateParam({
q: fd.get("q") as string,
tags: fd.get("tags") as string,
});
};
const clearFilters = () => {
router.push("/");
};
const hasFilters = currentQ || currentCategory || currentTags || (currentSort && currentSort !== "newest");
return (
<form
onSubmit={handleSubmit}
className="rounded-xl border bg-card p-4 shadow-sm space-y-3"
>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{/* Search */}
<div className="sm:col-span-2 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="q"
defaultValue={currentQ}
placeholder="Search posts…"
className="pl-9"
/>
</div>
{/* Category */}
<Select
defaultValue={currentCategory || "all"}
onValueChange={(val) =>
updateParam({ category: val === "all" ? "" : val })
}
>
<SelectTrigger>
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All categories</SelectItem>
{topCategories.map((c) => (
<SelectItem key={c.name} value={c.name}>
{c.name} ({c.count})
</SelectItem>
))}
</SelectContent>
</Select>
{/* Sort */}
<Select
defaultValue={currentSort || "newest"}
onValueChange={(val) => updateParam({ sort: val })}
>
<SelectTrigger>
<SelectValue placeholder="Sort" />
</SelectTrigger>
<SelectContent>
<SelectItem value="newest">Newest</SelectItem>
<SelectItem value="oldest">Oldest</SelectItem>
<SelectItem value="most_viewed">Most viewed</SelectItem>
<SelectItem value="featured">Featured first</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-2 items-end">
<div className="flex-1">
<Input
name="tags"
defaultValue={currentTags}
placeholder="Tags (comma separated): nextjs, typescript"
/>
</div>
<Button type="submit">Apply</Button>
{hasFilters && (
<Button type="button" variant="ghost" onClick={clearFilters}>
<X className="h-4 w-4" />
</Button>
)}
</div>
</form>
);
}

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import { ChevronDownIcon } from "lucide-react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,196 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,11 @@
"use client"
import { AspectRatio as AspectRatioPrimitive } from "radix-ui"
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }

View File

@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full ring-2 select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarBadge,
AvatarGroup,
AvatarGroupCount,
}

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

Some files were not shown because too many files have changed in this diff Show More