Frontend работает с mock Backend еще не отлажен.

This commit is contained in:
anibilag 2024-12-28 13:21:53 +03:00
parent 036d28cff9
commit 448a21649e
80 changed files with 4922 additions and 399 deletions

3
.env Normal file
View File

@ -0,0 +1,3 @@
DATABASE_URL="postgresql://lenin:8D2v7A4s@max.anibilag.ru:5466/russcult"
JWT_SECRET="d131c955dce9f6709acb06f2680b2916ac641f91b814bb0bd28872f9b1edc949"
PORT=5000

2806
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,8 @@
"server": "node server/index.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.525.0",
"@aws-sdk/s3-request-presigner": "^3.525.0",
"@prisma/client": "^5.10.2",
"@tiptap/pm": "^2.2.4",
"@tiptap/react": "^2.2.4",
@ -19,18 +21,27 @@
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.18.3",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.344.0",
"multer": "1.4.5-lts.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-dropzone": "^14.2.3",
"react-router-dom": "^6.22.3",
"sharp": "^0.33.2",
"uuid": "^9.0.1",
"winston": "^3.11.0",
"winston-daily-rotate-file": "^5.0.0",
"zustand": "^4.5.1"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@types/multer": "^1.4.11",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@types/uuid": "^9.0.8",
"@types/winston": "^2.4.4",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"eslint": "^9.9.1",
@ -44,4 +55,4 @@
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
}
}
}

View File

@ -20,7 +20,7 @@ model User {
}
model Article {
id String @id @default(uuid())
id String @id @default(uuid())
title String
excerpt String
content String
@ -28,11 +28,28 @@ model Article {
city String
coverImage String
readTime Int
likes Int @default(0)
dislikes Int @default(0)
publishedAt DateTime @default(now())
author User @relation(fields: [authorId], references: [id])
likes Int @default(0)
dislikes Int @default(0)
publishedAt DateTime @default(now())
author User @relation(fields: [authorId], references: [id])
authorId String
gallery GalleryImage[]
}
model GalleryImage {
id String @id @default(uuid())
url String
caption String
alt String
width Int
height Int
size Int
format String
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
articleId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order Int @default(0)
}
model UserReaction {

BIN
server.zip Normal file

Binary file not shown.

78
server/config/logger.ts Normal file
View File

@ -0,0 +1,78 @@
import * as winston from 'winston';
import 'winston-daily-rotate-file';
import * as path from 'path';
// Define log levels
const levels = {
error: 0,
warn: 1,
info: 2,
http: 3,
debug: 4,
};
// Define log level based on environment
const level = () => {
const env = process.env.NODE_ENV || 'development';
return env === 'development' ? 'debug' : 'warn';
};
// Define colors for each level
const colors = {
error: 'red',
warn: 'yellow',
info: 'green',
http: 'magenta',
debug: 'blue',
};
// Add colors to winston
winston.addColors(colors);
// Custom format for logging
const format = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }),
winston.format.colorize({ all: true }),
winston.format.printf(
(info) => `${info.timestamp} ${info.level}: ${info.message}`
)
);
// Define file transport options
const fileRotateTransport = new winston.transports.DailyRotateFile({
filename: path.join('logs', '%DATE%-server.log'),
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d',
format: winston.format.combine(
winston.format.uncolorize(),
winston.format.timestamp(),
winston.format.json()
),
});
// Create the logger
const logger = winston.createLogger({
level: level(),
levels,
format,
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
}),
fileRotateTransport,
],
});
// Create a stream object for Morgan middleware
const stream = {
write: (message: string) => {
logger.http(message.trim());
},
};
export { logger, stream };

View File

@ -3,7 +3,7 @@ import cors from 'cors';
import dotenv from 'dotenv';
import { PrismaClient } from '@prisma/client';
import authRoutes from './routes/auth.js';
//import articleRoutes from './routes/articles.js';
import articleRoutes from './routes/articles';
import userRoutes from './routes/users.js';
dotenv.config();
@ -16,7 +16,7 @@ app.use(express.json());
// Routes
app.use('/api/auth', authRoutes);
//app.use('/api/articles', articleRoutes);
app.use('/api/articles', articleRoutes);
app.use('/api/users', userRoutes);
const PORT = process.env.PORT || 5000;

View File

@ -1,24 +1,45 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import authRoutes from './routes/auth.js';
import articleRoutes from './routes/articles.js';
import userRoutes from './routes/users.js';
import { logger, stream } from './config/logger.js';
import { requestLogger } from './middleware/logging/requestLogger.js';
import { errorLogger } from './middleware/error/errorLogger.js';
import authRoutes from './routes/auth/index.js';
import articleRoutes from './routes/articles/index.js';
import userRoutes from './routes/users/index.js';
// Load environment variables
dotenv.config();
const app = express();
// Middleware
app.use(cors());
app.use(express.json());
app.use(requestLogger);
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/articles', articleRoutes);
app.use('/api/users', userRoutes);
// Error handling
app.use(errorLogger);
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
logger.info(`Server running on port ${PORT}`);
});
// Handle uncaught exceptions
process.on('uncaughtException', (error) => {
logger.error('Uncaught Exception:', error);
process.exit(1);
});
// Handle unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
process.exit(1);
});

View File

@ -1,28 +0,0 @@
import jwt from 'jsonwebtoken';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export const auth = async (req, res, next) => {
try {
const token = req.header('Authorization')?.replace('Bearer ', '');
if (!token) {
throw new Error();
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await prisma.user.findUnique({
where: { id: decoded.id }
});
if (!user) {
throw new Error();
}
req.user = user;
next();
} catch (error) {
res.status(401).json({ error: 'Please authenticate' });
}
};

View File

@ -1,40 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { PrismaClient } from '@prisma/client';
import { User } from '../../src/types/auth';
const prisma = new PrismaClient();
interface AuthRequest extends Request {
user?: User;
}
export const auth = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const token = req.header('Authorization')?.replace('Bearer ', '');
if (!token) {
throw new Error();
}
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'fallback-secret') as { id: string };
const user = await prisma.user.findUnique({
where: { id: decoded.id },
select: {
id: true,
email: true,
displayName: true,
permissions: true
}
});
if (!user) {
throw new Error();
}
req.user = user as User;
next();
} catch (error) {
res.status(401).json({ error: 'Please authenticate' });
}
};

View File

@ -0,0 +1,29 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from './types.js';
import { extractToken } from './extractToken.js';
import { validateToken } from './validateToken.js';
import { getUser } from './getUser.js';
export async function auth(req: AuthRequest, res: Response, next: NextFunction) {
try {
const token = extractToken(req);
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
const payload = validateToken(token);
if (!payload) {
return res.status(401).json({ error: 'Invalid token' });
}
const user = await getUser(payload.id);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
req.user = user;
next();
} catch {
res.status(401).json({ error: 'Authentication failed' });
}
}

View File

@ -0,0 +1,11 @@
import { Request } from 'express';
export function extractToken(req: Request): string | null {
const authHeader = req.header('Authorization');
if (!authHeader) return null;
const [bearer, token] = authHeader.split(' ');
if (bearer !== 'Bearer' || !token) return null;
return token;
}

View File

@ -0,0 +1,22 @@
import { PrismaClient } from '@prisma/client';
import { User } from '../../../src/types/auth.js';
const prisma = new PrismaClient();
export async function getUser(userId: string): Promise<User | null> {
try {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
displayName: true,
permissions: true
}
});
return user as User | null;
} catch {
return null;
}
}

View File

@ -0,0 +1,5 @@
export { auth } from './auth.js';
export { extractToken } from './extractToken.js';
export { validateToken } from './validateToken.js';
export { getUser } from './getUser.js';
export * from './types.js';

View File

@ -0,0 +1,12 @@
import { Request } from 'express';
import { User } from '../../../src/types/auth.js';
export interface AuthRequest extends Request {
user?: User;
}
export interface JwtPayload {
id: string;
iat?: number;
exp?: number;
}

View File

@ -0,0 +1,10 @@
import jwt from 'jsonwebtoken';
import { JwtPayload } from './types.js';
export function validateToken(token: string): JwtPayload | null {
try {
return jwt.verify(token, process.env.JWT_SECRET || '') as JwtPayload;
} catch {
return null;
}
}

View File

@ -0,0 +1,20 @@
import { Request, Response, NextFunction } from 'express';
export interface AppError extends Error {
statusCode?: number;
}
export function errorHandler(
err: AppError,
req: Request,
res: Response,
next: NextFunction
) {
const statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
res.status(statusCode).json({
error: message,
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
});
}

View File

@ -0,0 +1,27 @@
import { Request, Response, NextFunction } from 'express';
import { logger } from '../../config/logger.js';
export interface AppError extends Error {
statusCode?: number;
details?: never;
}
export const errorLogger = (
err: AppError,
req: Request,
res: Response,
next: NextFunction
) => {
const errorDetails = {
message: err.message,
stack: err.stack,
timestamp: new Date().toISOString(),
path: req.path,
method: req.method,
statusCode: err.statusCode || 500,
details: err.details,
};
logger.error('Application error:', errorDetails);
next(err);
};

View File

@ -0,0 +1,21 @@
import { Request, Response, NextFunction } from 'express';
import { logger } from '../../config/logger.js';
export const requestLogger = (req: Request, res: Response, next: NextFunction) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
const message = `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`;
if (res.statusCode >= 500) {
logger.error(message);
} else if (res.statusCode >= 400) {
logger.warn(message);
} else {
logger.info(message);
}
});
next();
};

View File

@ -0,0 +1,17 @@
import { Request, Response, NextFunction } from 'express';
import { Schema } from 'zod';
export function validateRequest(schema: Schema) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
await schema.parseAsync({
body: req.body,
query: req.query,
params: req.params
});
next();
} catch (error) {
res.status(400).json({ error: 'Invalid request data' });
}
};
}

View File

@ -1,91 +0,0 @@
import express from 'express';
import { PrismaClient } from '@prisma/client';
import { auth } from '../middleware/auth.js';
const router = express.Router();
const prisma = new PrismaClient();
// Search articles
router.get('/search', async (req, res) => {
try {
const { q, page = 1, limit = 9 } = req.query;
const skip = (page - 1) * limit;
const where = {
OR: [
{ title: { contains: q, mode: 'insensitive' } },
{ excerpt: { contains: q, mode: 'insensitive' } },
{ content: { contains: q, mode: 'insensitive' } },
]
};
const [articles, total] = await Promise.all([
prisma.article.findMany({
where,
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
},
skip,
take: parseInt(limit),
orderBy: { publishedAt: 'desc' }
}),
prisma.article.count({ where })
]);
res.json({
articles,
totalPages: Math.ceil(total / limit),
currentPage: parseInt(page)
});
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
// Get articles with pagination and filters
router.get('/', async (req, res) => {
try {
const { page = 1, category, city } = req.query;
const perPage = 6;
const where = {
...(category && { category }),
...(city && { city })
};
const [articles, total] = await Promise.all([
prisma.article.findMany({
where,
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
},
skip: (page - 1) * perPage,
take: perPage,
orderBy: { publishedAt: 'desc' }
}),
prisma.article.count({ where })
]);
res.json({
articles,
totalPages: Math.ceil(total / perPage),
currentPage: parseInt(page)
});
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
// Rest of the routes remain the same...

View File

@ -0,0 +1,123 @@
import { Request, Response } from 'express';
import { prisma } from '../../../../src/lib/prisma';
import { AuthRequest } from '../../../middleware/auth';
import { checkPermission } from '../../../utils/permissions.js';
export async function getArticle(req: Request, res: Response) {
try {
const article = await prisma.article.findUnique({
where: { id: req.params.id },
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
}
});
if (!article) {
return res.status(404).json({ error: 'Article not found' });
}
res.json(article);
} catch {
res.status(500).json({ error: 'Server error' });
}
}
export async function createArticle(req: AuthRequest, res: Response) {
try {
const { title, excerpt, content, category, city, coverImage, readTime } = req.body;
if (!req.user || !checkPermission(req.user, category, 'create')) {
return res.status(403).json({ error: 'Permission denied' });
}
const article = await prisma.article.create({
data: {
title,
excerpt,
content,
category,
city,
coverImage,
readTime,
authorId: req.user.id
},
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
}
});
res.status(201).json(article);
} catch {
res.status(500).json({ error: 'Server error' });
}
}
export async function updateArticle(req: AuthRequest, res: Response) {
try {
const article = await prisma.article.findUnique({
where: { id: req.params.id }
});
if (!article) {
return res.status(404).json({ error: 'Article not found' });
}
if (!req.user || !checkPermission(req.user, article.category, 'edit')) {
return res.status(403).json({ error: 'Permission denied' });
}
const updatedArticle = await prisma.article.update({
where: { id: req.params.id },
data: req.body,
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
}
});
res.json(updatedArticle);
} catch {
res.status(500).json({ error: 'Server error' });
}
}
export async function deleteArticle(req: AuthRequest, res: Response) {
try {
const article = await prisma.article.findUnique({
where: { id: req.params.id }
});
if (!article) {
return res.status(404).json({ error: 'Article not found' });
}
if (!req.user || !checkPermission(req.user, article.category, 'delete')) {
return res.status(403).json({ error: 'Permission denied' });
}
await prisma.article.delete({
where: { id: req.params.id }
});
res.json({ message: 'Article deleted successfully' });
} catch {
res.status(500).json({ error: 'Server error' });
}
}

View File

@ -0,0 +1,41 @@
import { Request, Response } from 'express';
import { prisma } from '../../../../src/lib/prisma';
export async function listArticles(req: Request, res: Response) {
try {
const { page = 1, category, city } = req.query;
const perPage = 6;
const where = {
...(category && { category: category as string }),
...(city && { city: city as string })
};
const [articles, total] = await Promise.all([
prisma.article.findMany({
where,
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
},
skip: ((page as number) - 1) * perPage,
take: perPage,
orderBy: { publishedAt: 'desc' }
}),
prisma.article.count({ where })
]);
res.json({
articles,
totalPages: Math.ceil(total / perPage),
currentPage: parseInt(page as string)
});
} catch {
res.status(500).json({ error: 'Server error' });
}
}

View File

@ -0,0 +1,44 @@
import { Request, Response } from 'express';
import { prisma } from '../../../../src/lib/prisma';
export async function searchArticles(req: Request, res: Response) {
try {
const { q, page = 1, limit = 9 } = req.query;
const skip = ((page as number) - 1) * (limit as number);
const where = {
OR: [
{ title: { contains: q as string, mode: 'insensitive' } },
{ excerpt: { contains: q as string, mode: 'insensitive' } },
{ content: { contains: q as string, mode: 'insensitive' } },
]
};
const [articles, total] = await Promise.all([
prisma.article.findMany({
where,
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
},
skip,
take: parseInt(limit as string),
orderBy: { publishedAt: 'desc' }
}),
prisma.article.count({ where })
]);
res.json({
articles,
totalPages: Math.ceil(total / (limit as number)),
currentPage: parseInt(page as string)
});
} catch {
res.status(500).json({ error: 'Server error' });
}
}

View File

@ -0,0 +1,93 @@
import { Request, Response } from 'express';
import { prisma } from '../../../src/lib/prisma';
export async function getArticle(req: Request, res: Response) {
try {
const article = await prisma.article.findUnique({
where: { id: req.params.id },
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
}
});
if (!article) {
return res.status(404).json({ error: 'Article not found' });
}
res.json(article);
} catch {
res.status(500).json({ error: 'Server error' });
}
}
export async function createArticle(req: Request, res: Response) {
try {
const { title, excerpt, content, category, city, coverImage, readTime } = req.body;
const article = await prisma.article.create({
data: {
title,
excerpt,
content,
category,
city,
coverImage,
readTime,
authorId: req.user!.id
},
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
}
});
res.status(201).json(article);
} catch {
res.status(500).json({ error: 'Server error' });
}
}
export async function updateArticle(req: Request, res: Response) {
try {
const article = await prisma.article.update({
where: { id: req.params.id },
data: req.body,
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
}
});
res.json(article);
} catch {
res.status(500).json({ error: 'Server error' });
}
}
export async function deleteArticle(req: Request, res: Response) {
try {
await prisma.article.delete({
where: { id: req.params.id }
});
res.json({ message: 'Article deleted successfully' });
} catch {
res.status(500).json({ error: 'Server error' });
}
}

View File

@ -0,0 +1,19 @@
import express from 'express';
import { auth } from '../../middleware/auth';
import { searchArticles } from './controllers/search.js';
import { listArticles } from './controllers/list.js';
import { getArticle, createArticle, updateArticle, deleteArticle } from './controllers/crud.js';
const router = express.Router();
// Search and list routes
router.get('/search', searchArticles);
router.get('/', listArticles);
// CRUD routes
router.get('/:id', getArticle);
router.post('/', auth, createArticle);
router.put('/:id', auth, updateArticle);
router.delete('/:id', auth, deleteArticle);
export default router;

View File

@ -0,0 +1,41 @@
import { Request, Response } from 'express';
import { prisma } from '../../../src/lib/prisma';
export async function listArticles(req: Request, res: Response) {
try {
const { page = 1, category, city } = req.query;
const perPage = 6;
const where = {
...(category && { category: category as string }),
...(city && { city: city as string })
};
const [articles, total] = await Promise.all([
prisma.article.findMany({
where,
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
},
skip: ((page as number) - 1) * perPage,
take: perPage,
orderBy: { publishedAt: 'desc' }
}),
prisma.article.count({ where })
]);
res.json({
articles,
totalPages: Math.ceil(total / perPage),
currentPage: parseInt(page as string)
});
} catch {
res.status(500).json({ error: 'Server error' });
}
}

View File

@ -0,0 +1,44 @@
import { Request, Response } from 'express';
import { prisma } from '../../../src/lib/prisma';
export async function searchArticles(req: Request, res: Response) {
try {
const { q, page = 1, limit = 9 } = req.query;
const skip = ((page as number) - 1) * (limit as number);
const where = {
OR: [
{ title: { contains: q as string, mode: 'insensitive' } },
{ excerpt: { contains: q as string, mode: 'insensitive' } },
{ content: { contains: q as string, mode: 'insensitive' } },
]
};
const [articles, total] = await Promise.all([
prisma.article.findMany({
where,
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
},
skip,
take: parseInt(limit as string),
orderBy: { publishedAt: 'desc' }
}),
prisma.article.count({ where })
]);
res.json({
articles,
totalPages: Math.ceil(total / (limit as number)),
currentPage: parseInt(page as string)
});
} catch {
res.status(500).json({ error: 'Server error' });
}
}

View File

@ -1,38 +0,0 @@
import express from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { PrismaClient } from '@prisma/client';
const router = express.Router();
const prisma = new PrismaClient();
// Login
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
const user = await prisma.user.findUnique({
where: { email }
});
if (!user || !await bcrypt.compare(password, user.password)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET);
res.json({
token,
user: {
id: user.id,
email: user.email,
displayName: user.displayName,
permissions: user.permissions
}
});
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
export default router;

View File

@ -1,6 +1,6 @@
import express from 'express';
import { authService } from '../services/authService.js';
import { auth } from '../middleware/auth.js';
import { auth } from '../middleware/auth';
const router = express.Router();
@ -10,7 +10,7 @@ router.post('/login', async (req, res) => {
const { email, password } = req.body;
const { user, token } = await authService.login(email, password);
res.json({ user, token });
} catch (error) {
} catch {
res.status(401).json({ error: 'Invalid credentials' });
}
});
@ -19,7 +19,7 @@ router.post('/login', async (req, res) => {
router.get('/me', auth, async (req, res) => {
try {
res.json(req.user);
} catch (error) {
} catch {
res.status(500).json({ error: 'Server error' });
}
});

View File

@ -0,0 +1,36 @@
import { Request, Response } from 'express';
import { AuthRequest } from '../../../middleware/auth';
import { authService } from '../../../services/authService.js';
export async function login(req: Request, res: Response) {
try {
const { email, password } = req.body;
const { user, token } = await authService.login(email, password);
res.json({ user, token });
} catch {
res.status(401).json({ error: 'Invalid credentials' });
}
}
export async function getCurrentUser(req: AuthRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
res.json(req.user);
} catch {
res.status(500).json({ error: 'Server error' });
}
}
export async function refreshToken(req: AuthRequest, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const token = await authService.generateToken(req.user.id);
res.json({ token });
} catch {
res.status(500).json({ error: 'Failed to refresh token' });
}
}

View File

@ -0,0 +1,12 @@
import express from 'express';
import { auth } from '../../middleware/auth';
import { login, getCurrentUser } from './controllers/auth.js';
import { validateRequest } from '../../middleware/validation/validateRequest.js';
import { loginSchema } from './validation/authSchemas.js';
const router = express.Router();
router.post('/login', validateRequest(loginSchema), login);
router.get('/me', auth, getCurrentUser);
export default router;

View File

@ -0,0 +1,8 @@
import { z } from 'zod';
export const loginSchema = z.object({
body: z.object({
email: z.string().email(),
password: z.string().min(6)
})
});

View File

@ -0,0 +1,81 @@
import { Request, Response } from 'express';
import { AuthRequest } from '../../../middleware/auth/types.js';
import { galleryService } from '../../../services/galleryService.js';
import { s3Service } from '../../../services/s3Service.js';
import { logger } from '../../../config/logger.js';
export async function createGalleryImage(req: AuthRequest, res: Response) {
try {
const { articleId } = req.params;
const { url, caption, alt, width, height, size, format } = req.body;
const image = await galleryService.createImage({
url,
caption,
alt,
width,
height,
size,
format,
articleId
});
res.status(201).json(image);
} catch (error) {
logger.error('Error creating gallery image:', error);
res.status(500).json({ error: 'Failed to create gallery image' });
}
}
export async function updateGalleryImage(req: AuthRequest, res: Response) {
try {
const { id } = req.params;
const { caption, alt, order } = req.body;
const image = await galleryService.updateImage(id, {
caption,
alt,
order
});
res.json(image);
} catch (error) {
logger.error('Error updating gallery image:', error);
res.status(500).json({ error: 'Failed to update gallery image' });
}
}
export async function deleteGalleryImage(req: AuthRequest, res: Response) {
try {
const { id } = req.params;
await galleryService.deleteImage(id);
res.json({ message: 'Gallery image deleted successfully' });
} catch (error) {
logger.error('Error deleting gallery image:', error);
res.status(500).json({ error: 'Failed to delete gallery image' });
}
}
export async function reorderGalleryImages(req: AuthRequest, res: Response) {
try {
const { articleId } = req.params;
const { imageIds } = req.body;
await galleryService.reorderImages(articleId, imageIds);
res.json({ message: 'Gallery images reordered successfully' });
} catch (error) {
logger.error('Error reordering gallery images:', error);
res.status(500).json({ error: 'Failed to reorder gallery images' });
}
}
export async function getArticleGallery(req: Request, res: Response) {
try {
const { articleId } = req.params;
const images = await galleryService.getArticleGallery(articleId);
res.json(images);
} catch (error) {
logger.error('Error fetching article gallery:', error);
res.status(500).json({ error: 'Failed to fetch gallery images' });
}
}

View File

@ -0,0 +1,19 @@
import express from 'express';
import { auth } from '../../middleware/auth';
import {
createGalleryImage,
updateGalleryImage,
deleteGalleryImage,
reorderGalleryImages,
getArticleGallery
} from './controllers/crud.js';
const router = express.Router();
router.get('/article/:articleId', getArticleGallery);
router.post('/article/:articleId', auth, createGalleryImage);
router.put('/:id', auth, updateGalleryImage);
router.delete('/:id', auth, deleteGalleryImage);
router.post('/article/:articleId/reorder', auth, reorderGalleryImages);
export default router;

View File

@ -0,0 +1,58 @@
import express from 'express';
import multer from 'multer';
import { auth } from '../../middleware/auth';
import { s3Service } from '../../services/s3Service.js';
import { logger } from '../../config/logger.js';
import { imageResolutions } from '../../../src/config/imageResolutions.js';
const router = express.Router();
const upload = multer();
router.post('/upload-url', auth, async (req, res) => {
try {
const { fileName, fileType, resolution } = req.body;
const selectedResolution = imageResolutions.find(r => r.id === resolution);
if (!selectedResolution) {
throw new Error('Invalid resolution');
}
const { uploadUrl, imageId, key } = await s3Service.getUploadUrl(fileName, fileType);
logger.info(`Generated upload URL for image: ${fileName}`);
res.json({ uploadUrl, imageId, key });
} catch (error) {
logger.error('Error generating upload URL:', error);
res.status(500).json({ error: 'Failed to generate upload URL' });
}
});
router.post('/process', auth, upload.single('image'), async (req, res) => {
try {
const { file } = req;
const { resolution } = req.body;
if (!file) {
throw new Error('No file uploaded');
}
const selectedResolution = imageResolutions.find(r => r.id === resolution);
if (!selectedResolution) {
throw new Error('Invalid resolution');
}
const result = await s3Service.optimizeAndUpload(
file.buffer,
file.originalname,
selectedResolution
);
logger.info(`Successfully processed image: ${file.originalname}`);
res.json(result);
} catch (error) {
logger.error('Error processing image:', error);
res.status(500).json({ error: 'Failed to process image' });
}
});
export default router;

View File

@ -1,93 +0,0 @@
import express from 'express';
import bcrypt from 'bcryptjs';
import { PrismaClient } from '@prisma/client';
import { auth } from '../middleware/auth.js';
const router = express.Router();
const prisma = new PrismaClient();
// Get all users (admin only)
router.get('/', auth, async (req, res) => {
try {
if (!req.user.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
displayName: true,
permissions: true,
isAdmin: true
}
});
res.json(users);
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
// Update user permissions (admin only)
router.put('/:id/permissions', auth, async (req, res) => {
try {
if (!req.user.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const { id } = req.params;
const { permissions } = req.body;
const user = await prisma.user.update({
where: { id },
data: { permissions },
select: {
id: true,
email: true,
displayName: true,
permissions: true,
isAdmin: true
}
});
res.json(user);
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
// Create new user (admin only)
router.post('/', auth, async (req, res) => {
try {
if (!req.user.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const { email, password, displayName, permissions } = req.body;
const hashedPassword = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: {
email,
password: hashedPassword,
displayName,
permissions
},
select: {
id: true,
email: true,
displayName: true,
permissions: true,
isAdmin: true
}
});
res.status(201).json(user);
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
export default router;

View File

@ -1,12 +1,14 @@
import express from 'express';
import { userService } from '../services/userService.js';
import { auth } from '../middleware/auth.js';
import { auth } from '../middleware/auth';
import { Request, Response } from 'express';
import { User } from '../../src/types/auth.ts';
const router = express.Router();
router.get('/', auth, async (req, res) => {
router.get('/', auth, async (req: Request, res: Response) => {
try {
if (!req.user.permissions.isAdmin) {
if (!req.user?.permissions.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const users = await userService.getUsers();
@ -16,9 +18,9 @@ router.get('/', auth, async (req, res) => {
}
});
router.put('/:id/permissions', auth, async (req, res) => {
router.put('/:id/permissions', auth, async (req: Request, res: Response) => {
try {
if (!req.user.permissions.isAdmin) {
if (!req.user?.permissions.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const { id } = req.params;

View File

@ -0,0 +1,29 @@
import { Response } from 'express';
import { AuthRequest } from '../../../middleware/auth';
import { userService } from '../../../services/userService.js';
export async function getUsers(req: AuthRequest, res: Response) {
try {
if (!req.user?.permissions.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const users = await userService.getUsers();
res.json(users);
} catch {
res.status(500).json({ error: 'Server error' });
}
}
export async function updateUserPermissions(req: AuthRequest, res: Response) {
try {
if (!req.user?.permissions.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const { id } = req.params;
const { permissions } = req.body;
const user = await userService.updateUserPermissions(id, permissions);
res.json(user);
} catch {
res.status(500).json({ error: 'Server error' });
}
}

View File

@ -0,0 +1,10 @@
import express from 'express';
import { auth } from '../../middleware/auth';
import { getUsers, updateUserPermissions } from './controllers/users.js';
const router = express.Router();
router.get('/', auth, getUsers);
router.put('/:id/permissions', auth, updateUserPermissions);
export default router;

View File

@ -1,38 +1,65 @@
import { PrismaClient } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { User } from '../../src/types/auth';
import { User } from '../../src/types/auth.js';
import { logger } from '../config/logger.js';
const prisma = new PrismaClient();
export const authService = {
login: async (email: string, password: string) => {
const user = await prisma.user.findUnique({
where: { email },
select: {
id: true,
email: true,
password: true,
displayName: true,
permissions: true
try {
logger.info(`Login attempt for user: ${email}`);
const user = await prisma.user.findUnique({
where: { email },
select: {
id: true,
email: true,
password: true,
displayName: true,
permissions: true
}
});
if (!user) {
logger.warn(`Login failed: User not found - ${email}`);
throw new Error('Invalid credentials');
}
});
if (!user || !await bcrypt.compare(password, user.password)) {
throw new Error('Invalid credentials');
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) {
logger.warn(`Login failed: Invalid password for user - ${email}`);
throw new Error('Invalid credentials');
}
const token = await authService.generateToken(user.id);
const { password: _, ...userWithoutPassword } = user;
logger.info(`User logged in successfully: ${email}`);
return {
user: userWithoutPassword as User,
token
};
} catch (error) {
logger.error('Login error:', error);
throw error;
}
},
const token = jwt.sign(
{ id: user.id },
process.env.JWT_SECRET || 'fallback-secret',
{ expiresIn: '24h' }
);
const { password: _, ...userWithoutPassword } = user;
return {
user: userWithoutPassword as User,
token
};
generateToken: async (userId: string) => {
try {
const token = jwt.sign(
{ id: userId },
process.env.JWT_SECRET || '',
{ expiresIn: '24h' }
);
logger.debug(`Generated token for user: ${userId}`);
return token;
} catch (error) {
logger.error('Token generation error:', error);
throw error;
}
},
createUser: async (userData: {
@ -41,21 +68,28 @@ export const authService = {
displayName: string;
permissions: any;
}) => {
const hashedPassword = await bcrypt.hash(userData.password, 10);
const user = await prisma.user.create({
data: {
...userData,
password: hashedPassword
},
select: {
id: true,
email: true,
displayName: true,
permissions: true
}
});
try {
logger.info(`Creating new user: ${userData.email}`);
const hashedPassword = await bcrypt.hash(userData.password, 10);
const user = await prisma.user.create({
data: {
...userData,
password: hashedPassword
},
select: {
id: true,
email: true,
displayName: true,
permissions: true
}
});
return user as User;
logger.info(`User created successfully: ${userData.email}`);
return user as User;
} catch (error) {
logger.error('User creation error:', error);
throw error;
}
}
};
};

View File

@ -0,0 +1,92 @@
import { PrismaClient } from '@prisma/client';
import { logger } from '../config/logger.js';
const prisma = new PrismaClient();
export const galleryService = {
createImage: async (data: {
url: string;
caption: string;
alt: string;
width: number;
height: number;
size: number;
format: string;
articleId: string;
order?: number;
}) => {
try {
const image = await prisma.galleryImage.create({
data
});
logger.info(`Created gallery image: ${image.id}`);
return image;
} catch (error) {
logger.error('Error creating gallery image:', error);
throw error;
}
},
updateImage: async (
id: string,
data: {
caption?: string;
alt?: string;
order?: number;
}
) => {
try {
const image = await prisma.galleryImage.update({
where: { id },
data
});
logger.info(`Updated gallery image: ${id}`);
return image;
} catch (error) {
logger.error(`Error updating gallery image ${id}:`, error);
throw error;
}
},
deleteImage: async (id: string) => {
try {
await prisma.galleryImage.delete({
where: { id }
});
logger.info(`Deleted gallery image: ${id}`);
} catch (error) {
logger.error(`Error deleting gallery image ${id}:`, error);
throw error;
}
},
reorderImages: async (articleId: string, imageIds: string[]) => {
try {
await prisma.$transaction(
imageIds.map((id, index) =>
prisma.galleryImage.update({
where: { id },
data: { order: index }
})
)
);
logger.info(`Reordered gallery images for article: ${articleId}`);
} catch (error) {
logger.error(`Error reordering gallery images for article ${articleId}:`, error);
throw error;
}
},
getArticleGallery: async (articleId: string) => {
try {
const images = await prisma.galleryImage.findMany({
where: { articleId },
orderBy: { order: 'asc' }
});
return images;
} catch (error) {
logger.error(`Error fetching gallery for article ${articleId}:`, error);
throw error;
}
}
};

View File

@ -0,0 +1,81 @@
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { v4 as uuidv4 } from 'uuid';
import sharp from 'sharp';
import { logger } from '../config/logger.js';
const s3Client = new S3Client({
region: process.env.AWS_REGION || 'us-east-1',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || ''
}
});
const BUCKET_NAME = process.env.AWS_S3_BUCKET || '';
export const s3Service = {
getUploadUrl: async (fileName: string, fileType: string) => {
const imageId = uuidv4();
const key = `uploads/${imageId}-${fileName}`;
const command = new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: key,
ContentType: fileType
});
try {
const uploadUrl = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
logger.info(`Generated pre-signed URL for upload: ${key}`);
return { uploadUrl, imageId, key };
} catch (error) {
logger.error('Error generating pre-signed URL:', error);
throw error;
}
},
optimizeAndUpload: async (buffer: Buffer, key: string, resolution: { width: number; height: number }) => {
try {
let sharpInstance = sharp(buffer);
// Get image metadata
const metadata = await sharpInstance.metadata();
// Resize if resolution is specified
if (resolution.width > 0 && resolution.height > 0) {
sharpInstance = sharpInstance.resize(resolution.width, resolution.height, {
fit: 'inside',
withoutEnlargement: true
});
}
// Convert to WebP for better compression
const optimizedBuffer = await sharpInstance
.webp({ quality: 80 })
.toBuffer();
// Upload optimized image
const optimizedKey = key.replace(/\.[^/.]+$/, '.webp');
await s3Client.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: optimizedKey,
Body: optimizedBuffer,
ContentType: 'image/webp'
}));
logger.info(`Successfully optimized and uploaded image: ${optimizedKey}`);
return {
key: optimizedKey,
width: metadata.width,
height: metadata.height,
format: 'webp',
size: optimizedBuffer.length
};
} catch (error) {
logger.error('Error optimizing and uploading image:', error);
throw error;
}
}
};

View File

@ -0,0 +1,16 @@
import { Category, City } from '../../src/types';
import { User } from '../../src/types/auth';
export const checkPermission = (
user: User,
category: Category,
action: 'create' | 'edit' | 'delete'
): boolean => {
if (user.permissions.isAdmin) return true;
return !!user.permissions.categories[category]?.[action];
};
export const checkCityAccess = (user: User, city: City): boolean => {
if (user.permissions.isAdmin) return true;
return user.permissions.cities.includes(city);
};

View File

@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import axios from 'axios';
import { useAuthStore } from './stores/authStore';
@ -8,6 +8,7 @@ import { AdminPage } from './pages/AdminPage';
import { LoginPage } from './pages/LoginPage';
import { UserManagementPage } from './pages/UserManagementPage';
import { SearchPage } from './pages/SearchPage';
import { BookmarksPage } from './pages/BookmarksPage';
import { Footer } from './components/Footer';
import { AuthGuard } from './components/AuthGuard';
@ -44,6 +45,7 @@ function App() {
<Route path="/" element={<HomePage />} />
<Route path="/article/:id" element={<ArticlePage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
<Route path="/login" element={<LoginPage />} />
<Route
path="/admin"

View File

@ -1,4 +1,3 @@
import React from 'react';
import { Clock, ThumbsUp, MapPin } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Article } from '../types';

View File

@ -0,0 +1,40 @@
import React from 'react';
import { Bookmark } from 'lucide-react';
import { useBookmarkStore } from '../stores/bookmarkStore';
import { Article } from '../types';
interface BookmarkButtonProps {
article: Article;
className?: string;
}
export function BookmarkButton({ article, className = '' }: BookmarkButtonProps) {
const { isBookmarked, addBookmark, removeBookmark } = useBookmarkStore();
const bookmarked = isBookmarked(article.id);
const handleBookmark = () => {
if (bookmarked) {
removeBookmark(article.id);
} else {
addBookmark(article);
}
};
return (
<button
onClick={handleBookmark}
className={`group relative p-2 rounded-full hover:bg-gray-100 transition-colors ${className}`}
aria-label={bookmarked ? 'Remove from bookmarks' : 'Add to bookmarks'}
>
<Bookmark
size={20}
className={`transition-colors ${
bookmarked ? 'fill-blue-600 text-blue-600' : 'text-gray-500 group-hover:text-gray-700'
}`}
/>
<span className="absolute -bottom-8 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
{bookmarked ? 'Remove bookmark' : 'Add bookmark'}
</span>
</button>
);
}

View File

@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react';
import { useState, useMemo } from 'react';
import { useLocation, useSearchParams } from 'react-router-dom';
import { ArticleCard } from './ArticleCard';
import { Pagination } from './Pagination';

View File

@ -0,0 +1,16 @@
import React from 'react';
import { Palette } from 'lucide-react';
export function DesignStudioLogo() {
return (
<a
href="https://stackblitz.com"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
>
<Palette size={16} />
<span className="text-sm">Designed by StackBlitz Studio</span>
</a>
);
}

View File

@ -1,6 +1,7 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Mail, Phone, Instagram, Twitter, Facebook, ExternalLink } from 'lucide-react';
import { DesignStudioLogo } from './DesignStudioLogo';
export function Footer() {
return (
@ -119,9 +120,13 @@ export function Footer() {
<div className="border-t border-gray-800 mt-12 pt-8">
<div className="flex flex-col md:flex-row justify-between items-center">
<p className="text-sm text-gray-400">
© {new Date().getFullYear()} CultureScope. All rights reserved.
</p>
<div className="flex items-center space-x-2">
<p className="text-sm text-gray-400">
© {new Date().getFullYear()} CultureScope. All rights reserved.
</p>
<span className="text-gray-600"></span>
<DesignStudioLogo />
</div>
<div className="flex space-x-6 mt-4 md:mt-0">
<Link to="/privacy" className="text-sm text-gray-400 hover:text-white transition-colors">
Privacy Policy

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import { useState } from 'react';
import { X, Plus, Move, Pencil, Trash2 } from 'lucide-react';
import { GalleryImage } from '../types';

View File

@ -1,4 +1,3 @@
import React from 'react';
import { Move, Pencil, Trash2 } from 'lucide-react';
import { GalleryImage } from '../../types';

View File

@ -1,4 +1,3 @@
import React from 'react';
import { Plus } from 'lucide-react';
interface ImageFormProps {

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import { useState } from 'react';
import { GalleryImage } from '../../types';
import { GalleryGrid } from './GalleryGrid';
import { ImageForm } from './ImageForm';

View File

@ -0,0 +1,47 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { ChevronDown } from 'lucide-react';
import { City } from '../../types';
interface CitySelectorProps {
cities: City[];
currentCity: string | null;
}
export function CitySelector({ cities, currentCity }: CitySelectorProps) {
const navigate = useNavigate();
const handleCityChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const city = event.target.value;
const params = new URLSearchParams(window.location.search);
if (city) {
params.set('city', city);
} else {
params.delete('city');
}
navigate(`/?${params.toString()}`);
};
return (
<div className="relative">
<select
value={currentCity || ''}
onChange={handleCityChange}
className="appearance-none bg-white pl-3 pr-8 py-2 text-sm font-medium rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent cursor-pointer"
>
<option value="">All Cities</option>
{cities.map((city) => (
<option key={city} value={city}>
{city}
</option>
))}
</select>
<ChevronDown
size={16}
className="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-400 pointer-events-none"
/>
</div>
);
}

View File

@ -0,0 +1,11 @@
import { Link } from 'react-router-dom';
import { Palette } from 'lucide-react';
export function Logo() {
return (
<Link to="/" className="flex items-center space-x-2 text-gray-900">
<Palette size={32} className="text-blue-600" />
<span className="text-2xl font-bold">CultureScope</span>
</Link>
);
}

View File

@ -0,0 +1,74 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Category, City } from '../../types';
interface MobileMenuProps {
isOpen: boolean;
categories: Category[];
cities: City[];
currentCategory: string | null;
currentCity: string | null;
onCityChange: (event: React.ChangeEvent<HTMLSelectElement>) => void;
}
export function MobileMenu({
isOpen,
categories,
cities,
currentCategory,
currentCity,
onCityChange
}: MobileMenuProps) {
if (!isOpen) return null;
return (
<div className="lg:hidden border-b border-gray-200">
<div className="px-2 pt-2 pb-3 space-y-1">
<div className="px-3 py-2">
<h3 className="text-sm font-medium text-gray-500 mb-2">City</h3>
<select
value={currentCity || ''}
onChange={onCityChange}
className="w-full bg-white px-3 py-2 text-base font-medium rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">All Cities</option>
{cities.map((city) => (
<option key={city} value={city}>
{city}
</option>
))}
</select>
</div>
<div className="px-3 py-2">
<h3 className="text-sm font-medium text-gray-500 mb-2">Categories</h3>
<div className="space-y-1">
<Link
to={currentCity ? `/?city=${currentCity}` : '/'}
className={`block px-3 py-2 rounded-md text-base font-medium ${
!currentCategory
? 'bg-blue-50 text-blue-600'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
All Categories
</Link>
{categories.map((category) => (
<Link
key={category}
to={`/?category=${category}${currentCity ? `&city=${currentCity}` : ''}`}
className={`block px-3 py-2 rounded-md text-base font-medium ${
currentCategory === category
? 'bg-blue-50 text-blue-600'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
{category}
</Link>
))}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,39 @@
import { Link, useLocation } from 'react-router-dom';
import { Category } from '../../types';
interface NavigationProps {
categories: Category[];
currentCategory: string | null;
currentCity: string | null;
}
export function Navigation({ categories, currentCategory, currentCity }: NavigationProps) {
return (
<div className="flex items-center space-x-4 overflow-x-auto">
<span className="text-sm font-medium text-gray-500"></span>
<Link
to={currentCity ? `/?city=${currentCity}` : '/'}
className={`px-3 py-2 text-sm font-medium transition-colors whitespace-nowrap ${
!currentCategory
? 'text-blue-600 hover:text-blue-800'
: 'text-gray-600 hover:text-gray-900'
}`}
>
All Categories
</Link>
{categories.map((category) => (
<Link
key={category}
to={`/?category=${category}${currentCity ? `&city=${currentCity}` : ''}`}
className={`px-3 py-2 text-sm font-medium transition-colors whitespace-nowrap ${
currentCategory === category
? 'text-blue-600 hover:text-blue-800'
: 'text-gray-600 hover:text-gray-900'
}`}
>
{category}
</Link>
))}
</div>
);
}

View File

@ -0,0 +1,40 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search } from 'lucide-react';
export function SearchBar() {
const [searchQuery, setSearchQuery] = useState('');
const navigate = useNavigate();
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim()) {
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
}
};
const handleSearchKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch(e);
}
};
return (
<form onSubmit={handleSearch} className="relative">
<input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={handleSearchKeyPress}
className="w-40 lg:w-60 pl-10 pr-4 py-2 text-sm border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<button
type="submit"
className="absolute left-3 top-2.5 text-gray-400 hover:text-gray-600"
>
<Search size={18} />
</button>
</form>
);
}

View File

@ -0,0 +1,94 @@
import React, { useState } from 'react';
import { Menu, X, Bookmark } from 'lucide-react';
import { Link, useLocation } from 'react-router-dom';
import { Category, City } from '../../types';
import { Logo } from './Logo';
import { SearchBar } from './SearchBar';
import { Navigation } from './Navigation';
import { CitySelector } from './CitySelector';
import { MobileMenu } from './MobileMenu';
import { useBookmarkStore } from '../../stores/bookmarkStore';
const categories: Category[] = ['Film', 'Theater', 'Music', 'Sports', 'Art', 'Legends', 'Anniversaries', 'Memory'];
const cities: City[] = ['New York', 'London'];
export function Header() {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
const currentCategory = searchParams.get('category');
const currentCity = searchParams.get('city');
const { bookmarks } = useBookmarkStore();
const handleCityChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const city = event.target.value;
const params = new URLSearchParams(location.search);
if (city) {
params.set('city', city);
} else {
params.delete('city');
}
window.location.href = `/?${params.toString()}`;
};
return (
<header className="sticky top-0 z-50 bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center">
<button
className="p-2 rounded-md text-gray-500 lg:hidden hover:bg-gray-100"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
</button>
<div className="ml-2">
<Logo />
</div>
</div>
<nav className="hidden lg:flex items-center space-x-8">
<div className="flex items-center space-x-4">
<span className="text-sm font-medium text-gray-500"></span>
<CitySelector cities={cities} currentCity={currentCity} />
</div>
<div className="h-6 w-px bg-gray-200" />
<Navigation
categories={categories}
currentCategory={currentCategory}
currentCity={currentCity}
/>
</nav>
<div className="flex items-center space-x-4">
<SearchBar />
<Link
to="/bookmarks"
className="relative p-2 rounded-full hover:bg-gray-100 transition-colors"
>
<Bookmark size={20} className="text-gray-500" />
{bookmarks.length > 0 && (
<span className="absolute -top-1 -right-1 bg-blue-600 text-white text-xs w-4 h-4 flex items-center justify-center rounded-full">
{bookmarks.length}
</span>
)}
</Link>
</div>
</div>
</div>
<MobileMenu
isOpen={isMobileMenuOpen}
categories={categories}
cities={cities}
currentCategory={currentCategory}
currentCity={currentCity}
onCityChange={handleCityChange}
/>
</header>
);
}

View File

@ -0,0 +1,45 @@
import React, { useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import { ImagePlus } from 'lucide-react';
interface ImageDropzoneProps {
onDrop: (files: File[]) => void;
disabled?: boolean;
}
export function ImageDropzone({ onDrop, disabled = false }: ImageDropzoneProps) {
const handleDrop = useCallback((acceptedFiles: File[]) => {
onDrop(acceptedFiles);
}, [onDrop]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: handleDrop,
accept: {
'image/*': ['.png', '.jpg', '.jpeg', '.webp']
},
disabled,
maxSize: 10 * 1024 * 1024 // 10MB
});
return (
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors
${isDragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-gray-400'}
${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<input {...getInputProps()} />
<ImagePlus className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-2 text-sm text-gray-600">
{isDragActive ? (
'Drop the image here'
) : (
'Drag & drop an image here, or click to select'
)}
</p>
<p className="mt-1 text-xs text-gray-500">
PNG, JPG, JPEG or WebP up to 10MB
</p>
</div>
);
}

View File

@ -0,0 +1,81 @@
import React, { useState } from 'react';
import { ImageDropzone } from './ImageDropzone';
import { ResolutionSelect } from './ResolutionSelect';
import { UploadProgress } from './UploadProgress';
import { imageResolutions } from '../../config/imageResolutions';
import { uploadImage } from '../../services/imageService';
import { ImageUploadProgress } from '../../types/image';
interface ImageUploaderProps {
onUploadComplete: (imageUrl: string) => void;
}
export function ImageUploader({ onUploadComplete }: ImageUploaderProps) {
const [selectedResolution, setSelectedResolution] = useState('medium');
const [uploadProgress, setUploadProgress] = useState<Record<string, ImageUploadProgress>>({});
const handleDrop = async (files: File[]) => {
for (const file of files) {
try {
setUploadProgress(prev => ({
...prev,
[file.name]: { progress: 0, status: 'uploading' }
}));
const resolution = imageResolutions.find(r => r.id === selectedResolution);
if (!resolution) throw new Error('Invalid resolution selected');
const uploadedImage = await uploadImage(file, resolution, (progress) => {
setUploadProgress(prev => ({
...prev,
[file.name]: { progress, status: 'uploading' }
}));
});
setUploadProgress(prev => ({
...prev,
[file.name]: { progress: 100, status: 'complete' }
}));
onUploadComplete(uploadedImage.url);
} catch (error) {
setUploadProgress(prev => ({
...prev,
[file.name]: {
progress: 0,
status: 'error',
error: error instanceof Error ? error.message : 'Upload failed'
}
}));
}
}
};
const isUploading = Object.values(uploadProgress).some(
p => p.status === 'uploading' || p.status === 'processing'
);
return (
<div className="space-y-6">
<ResolutionSelect
resolutions={imageResolutions}
selectedResolution={selectedResolution}
onChange={setSelectedResolution}
disabled={isUploading}
/>
<ImageDropzone
onDrop={handleDrop}
disabled={isUploading}
/>
{Object.entries(uploadProgress).map(([fileName, progress]) => (
<UploadProgress
key={fileName}
fileName={fileName}
progress={progress}
/>
))}
</div>
);
}

View File

@ -0,0 +1,36 @@
import React from 'react';
import { ImageResolution } from '../../types/image';
interface ResolutionSelectProps {
resolutions: ImageResolution[];
selectedResolution: string;
onChange: (resolutionId: string) => void;
disabled?: boolean;
}
export function ResolutionSelect({
resolutions,
selectedResolution,
onChange,
disabled = false
}: ResolutionSelectProps) {
return (
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Select Resolution
</label>
<select
value={selectedResolution}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50"
>
{resolutions.map((resolution) => (
<option key={resolution.id} value={resolution.id}>
{resolution.label}
</option>
))}
</select>
</div>
);
}

View File

@ -0,0 +1,56 @@
import React from 'react';
import { ImageUploadProgress } from '../../types/image';
import { CheckCircle, AlertCircle, Loader } from 'lucide-react';
interface UploadProgressProps {
progress: ImageUploadProgress;
fileName: string;
}
export function UploadProgress({ progress, fileName }: UploadProgressProps) {
const getStatusIcon = () => {
switch (progress.status) {
case 'complete':
return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'error':
return <AlertCircle className="h-5 w-5 text-red-500" />;
default:
return <Loader className="h-5 w-5 text-blue-500 animate-spin" />;
}
};
const getStatusText = () => {
switch (progress.status) {
case 'uploading':
return 'Uploading...';
case 'processing':
return 'Processing...';
case 'complete':
return 'Upload complete';
case 'error':
return progress.error || 'Upload failed';
}
};
return (
<div className="flex items-center space-x-4 p-4 bg-gray-50 rounded-lg">
{getStatusIcon()}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{fileName}
</p>
<p className="text-sm text-gray-500">{getStatusText()}</p>
</div>
{(progress.status === 'uploading' || progress.status === 'processing') && (
<div className="w-24">
<div className="bg-gray-200 rounded-full h-2.5">
<div
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
style={{ width: `${progress.progress}%` }}
/>
</div>
</div>
)}
</div>
);
}

View File

@ -1,4 +1,3 @@
import React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
interface PaginationProps {

View File

@ -1,10 +1,9 @@
import React from 'react';
import { ThumbsUp, ThumbsDown } from 'lucide-react';
interface ReactionButtonsProps {
likes: number;
dislikes: number;
userReaction: 'like' | 'dislike' | null;
userReaction: 'like' | 'dislike' | null | undefined;
onReact: (reaction: 'like' | 'dislike') => void;
}

View File

@ -0,0 +1,28 @@
import { ImageResolution } from '../types/image';
export const imageResolutions: ImageResolution[] = [
{
id: 'thumbnail',
width: 300,
height: 300,
label: 'Thumbnail (300x300)'
},
{
id: 'medium',
width: 800,
height: 600,
label: 'Medium (800x600)'
},
{
id: 'large',
width: 1920,
height: 1080,
label: 'Large (1920x1080)'
},
{
id: 'original',
width: 0, // 0 means keep original dimensions
height: 0,
label: 'Original Size'
}
];

View File

@ -1,4 +1,4 @@
import { Article, Author, City } from '../types';
import { Article, Author } from '../types';
export const authors: Author[] = [
{
@ -88,7 +88,7 @@ export const articles: Article[] = [
title: 'Modern Literature in the Digital Age',
excerpt: 'How e-books and digital platforms are changing the way we consume and create literature.',
content: 'The digital revolution has transformed the literary landscape, offering new ways for authors to reach readers and for stories to be told.',
category: 'Literature',
category: 'Art',
city: 'New York',
author: authors[0],
coverImage: 'https://images.unsplash.com/photo-1524995997946-a1c2e315a42f?auto=format&fit=crop&q=80&w=2070',

94
src/hooks/useGallery.ts Normal file
View File

@ -0,0 +1,94 @@
import { useState, useEffect } from 'react';
import { GalleryImage } from '../types';
import { galleryService } from '../services/galleryService';
import { uploadImage } from '../services/imageService';
import { ImageResolution } from '../types/image';
export function useGallery(articleId: string) {
const [images, setImages] = useState<GalleryImage[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadGallery();
}, [articleId]);
const loadGallery = async () => {
try {
setLoading(true);
const galleryImages = await galleryService.getArticleGallery(articleId);
setImages(galleryImages);
setError(null);
} catch (err) {
setError('Failed to load gallery images');
console.error('Error loading gallery:', err);
} finally {
setLoading(false);
}
};
const addImage = async (file: File, resolution: ImageResolution) => {
try {
const uploadedImage = await uploadImage(file, resolution);
const galleryImage = await galleryService.createImage(articleId, {
url: uploadedImage.url,
caption: '',
alt: file.name,
width: uploadedImage.width,
height: uploadedImage.height,
size: uploadedImage.size,
format: uploadedImage.format
});
setImages([...images, galleryImage]);
return galleryImage;
} catch (err) {
console.error('Error adding image:', err);
throw err;
}
};
const updateImage = async (id: string, updates: Partial<GalleryImage>) => {
try {
const updatedImage = await galleryService.updateImage(id, updates);
setImages(images.map(img => img.id === id ? updatedImage : img));
return updatedImage;
} catch (err) {
console.error('Error updating image:', err);
throw err;
}
};
const deleteImage = async (id: string) => {
try {
await galleryService.deleteImage(id);
setImages(images.filter(img => img.id !== id));
} catch (err) {
console.error('Error deleting image:', err);
throw err;
}
};
const reorderImages = async (imageIds: string[]) => {
try {
await galleryService.reorderImages(articleId, imageIds);
const reorderedImages = imageIds.map(id =>
images.find(img => img.id === id)!
);
setImages(reorderedImages);
} catch (err) {
console.error('Error reordering images:', err);
throw err;
}
};
return {
images,
loading,
error,
addImage,
updateImage,
deleteImage,
reorderImages,
refresh: loadGallery
};
}

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { ArrowLeft, Clock, Share2, Bookmark } from 'lucide-react';
import { Header } from '../components/Header';

View File

@ -0,0 +1,38 @@
import { Header } from '../components/Header';
import { ArticleCard } from '../components/ArticleCard';
import { useBookmarkStore } from '../stores/bookmarkStore';
import { Bookmark } from 'lucide-react';
export function BookmarksPage() {
const { bookmarks } = useBookmarkStore();
return (
<div className="min-h-screen bg-gray-50">
<Header />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="flex items-center space-x-4 mb-8">
<Bookmark size={24} className="text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">Your Bookmarks</h1>
</div>
{bookmarks.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{bookmarks.map((article) => (
<ArticleCard key={article.id} article={article} />
))}
</div>
) : (
<div className="text-center py-12">
<Bookmark size={48} className="mx-auto text-gray-400 mb-4" />
<h2 className="text-xl font-medium text-gray-900 mb-2">
No bookmarks yet
</h2>
<p className="text-gray-500">
Start bookmarking articles you want to read later
</p>
</div>
)}
</main>
</div>
);
}

View File

@ -1,4 +1,3 @@
import React from 'react';
import { useSearchParams } from 'react-router-dom';
import { Header } from '../components/Header';
import { FeaturedSection } from '../components/FeaturedSection';

View File

@ -1,14 +1,8 @@
import React, { useState } from 'react';
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../stores/authStore';
import { AlertCircle } from 'lucide-react';
// Temporary admin credentials - DEVELOPMENT ONLY
const TEMP_ADMIN = {
email: 'admin@culturescope.com',
password: 'admin123'
};
export function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@ -27,7 +21,7 @@ export function LoginPage() {
await login(email, password); // Pass the admin user object
navigate(from, { replace: true });
} catch (err) {
setError('An error occurred while logging in');
setError('An error occurred while logging in ' + err);
}
};
@ -36,7 +30,7 @@ export function LoginPage() {
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to your account
Войдите в свой эккаунт
</h2>
{/* Temporary credentials notice */}
<div className="mt-4 p-4 bg-blue-50 rounded-md">
@ -109,7 +103,7 @@ export function LoginPage() {
type="submit"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Sign in
Вход
</button>
</div>
</form>

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Header } from '../components/Header';
import { ArticleCard } from '../components/ArticleCard';

View File

@ -0,0 +1,27 @@
import axios from '../utils/api';
import { GalleryImage } from '../types';
export const galleryService = {
createImage: async (articleId: string, imageData: Omit<GalleryImage, 'id'>) => {
const { data } = await axios.post(`/gallery/article/${articleId}`, imageData);
return data;
},
updateImage: async (id: string, updates: Partial<GalleryImage>) => {
const { data } = await axios.put(`/gallery/${id}`, updates);
return data;
},
deleteImage: async (id: string) => {
await axios.delete(`/gallery/${id}`);
},
reorderImages: async (articleId: string, imageIds: string[]) => {
await axios.post(`/gallery/article/${articleId}/reorder`, { imageIds });
},
getArticleGallery: async (articleId: string) => {
const { data } = await axios.get(`/gallery/article/${articleId}`);
return data as GalleryImage[];
}
};

View File

@ -0,0 +1,32 @@
import axios from '../utils/api';
import { ImageResolution, UploadedImage } from '../types/image';
export async function uploadImage(
file: File,
resolution: ImageResolution,
onProgress?: (progress: number) => void
): Promise<UploadedImage> {
// Get pre-signed URL for S3 upload
const { data: { uploadUrl, imageId } } = await axios.post('/images/upload-url', {
fileName: file.name,
fileType: file.type,
resolution: resolution.id
});
// Upload to S3
await axios.put(uploadUrl, file, {
headers: {
'Content-Type': file.type
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
onProgress?.(progress);
}
}
});
// Get the processed image details
const { data: image } = await axios.get(`/images/${imageId}`);
return image;
}

View File

@ -0,0 +1,36 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { Article } from '../types';
interface BookmarkState {
bookmarks: Article[];
addBookmark: (article: Article) => void;
removeBookmark: (articleId: string) => void;
isBookmarked: (articleId: string) => boolean;
}
export const useBookmarkStore = create<BookmarkState>()(
persist(
(set, get) => ({
bookmarks: [],
addBookmark: (article) => {
if (!get().isBookmarked(article.id)) {
set((state) => ({
bookmarks: [...state.bookmarks, article],
}));
}
},
removeBookmark: (articleId) => {
set((state) => ({
bookmarks: state.bookmarks.filter((article) => article.id !== articleId),
}));
},
isBookmarked: (articleId) => {
return get().bookmarks.some((article) => article.id === articleId);
},
}),
{
name: 'bookmarks-storage',
}
)
);

21
src/types/image.ts Normal file
View File

@ -0,0 +1,21 @@
export interface ImageResolution {
id: string;
width: number;
height: number;
label: string;
}
export interface UploadedImage {
id: string;
url: string;
width: number;
height: number;
size: number;
format: string;
}
export interface ImageUploadProgress {
progress: number;
status: 'uploading' | 'processing' | 'complete' | 'error';
error?: string;
}

View File

@ -3,5 +3,12 @@
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
],
"compilerOptions": {
"target": "esnext", // Compile to a modern ECMAScript version that supports ES modules
"module": "esnext", // Use ES module syntax for module code generation
"moduleResolution": "node", // Use Node.js style module resolution
"esModuleInterop": true, // Enables default imports from modules with no default export
"outDir": "./dist" // Output directory for compiled files
}
}

View File

@ -7,6 +7,7 @@ export default defineConfig({
exclude: ['@prisma/client', 'lucide-react'],
},
server: {
host: true,
proxy: {
'/api': {
target: 'http://localhost:5000',