Frontend работает с mock Backend еще не отлажен.
This commit is contained in:
parent
036d28cff9
commit
448a21649e
3
.env
Normal file
3
.env
Normal 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
2806
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
BIN
server.zip
Normal file
Binary file not shown.
78
server/config/logger.ts
Normal file
78
server/config/logger.ts
Normal 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 };
|
@ -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;
|
||||
|
@ -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);
|
||||
});
|
@ -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' });
|
||||
}
|
||||
};
|
@ -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' });
|
||||
}
|
||||
};
|
29
server/middleware/auth/auth.ts
Normal file
29
server/middleware/auth/auth.ts
Normal 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' });
|
||||
}
|
||||
}
|
11
server/middleware/auth/extractToken.ts
Normal file
11
server/middleware/auth/extractToken.ts
Normal 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;
|
||||
}
|
22
server/middleware/auth/getUser.ts
Normal file
22
server/middleware/auth/getUser.ts
Normal 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;
|
||||
}
|
||||
}
|
5
server/middleware/auth/index.ts
Normal file
5
server/middleware/auth/index.ts
Normal 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';
|
12
server/middleware/auth/types.ts
Normal file
12
server/middleware/auth/types.ts
Normal 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;
|
||||
}
|
10
server/middleware/auth/validateToken.ts
Normal file
10
server/middleware/auth/validateToken.ts
Normal 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;
|
||||
}
|
||||
}
|
20
server/middleware/error/errorHandler.ts
Normal file
20
server/middleware/error/errorHandler.ts
Normal 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
|
||||
});
|
||||
}
|
27
server/middleware/error/errorLogger.ts
Normal file
27
server/middleware/error/errorLogger.ts
Normal 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);
|
||||
};
|
21
server/middleware/logging/requestLogger.ts
Normal file
21
server/middleware/logging/requestLogger.ts
Normal 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();
|
||||
};
|
17
server/middleware/validation/validateRequest.ts
Normal file
17
server/middleware/validation/validateRequest.ts
Normal 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' });
|
||||
}
|
||||
};
|
||||
}
|
@ -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...
|
123
server/routes/articles/controllers/crud.ts
Normal file
123
server/routes/articles/controllers/crud.ts
Normal 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' });
|
||||
}
|
||||
}
|
41
server/routes/articles/controllers/list.ts
Normal file
41
server/routes/articles/controllers/list.ts
Normal 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' });
|
||||
}
|
||||
}
|
44
server/routes/articles/controllers/search.ts
Normal file
44
server/routes/articles/controllers/search.ts
Normal 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' });
|
||||
}
|
||||
}
|
93
server/routes/articles/crud.ts
Normal file
93
server/routes/articles/crud.ts
Normal 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' });
|
||||
}
|
||||
}
|
19
server/routes/articles/index.ts
Normal file
19
server/routes/articles/index.ts
Normal 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;
|
41
server/routes/articles/list.ts
Normal file
41
server/routes/articles/list.ts
Normal 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' });
|
||||
}
|
||||
}
|
44
server/routes/articles/search.ts
Normal file
44
server/routes/articles/search.ts
Normal 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' });
|
||||
}
|
||||
}
|
@ -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;
|
@ -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' });
|
||||
}
|
||||
});
|
||||
|
36
server/routes/auth/controllers/auth.ts
Normal file
36
server/routes/auth/controllers/auth.ts
Normal 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' });
|
||||
}
|
||||
}
|
12
server/routes/auth/index.ts
Normal file
12
server/routes/auth/index.ts
Normal 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;
|
8
server/routes/auth/validation/authSchemas.ts
Normal file
8
server/routes/auth/validation/authSchemas.ts
Normal 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)
|
||||
})
|
||||
});
|
81
server/routes/gallery/controllers/crud.ts
Normal file
81
server/routes/gallery/controllers/crud.ts
Normal 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' });
|
||||
}
|
||||
}
|
19
server/routes/gallery/index.ts
Normal file
19
server/routes/gallery/index.ts
Normal 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;
|
58
server/routes/images/index.ts
Normal file
58
server/routes/images/index.ts
Normal 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;
|
@ -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;
|
@ -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;
|
||||
|
29
server/routes/users/controllers/users.ts
Normal file
29
server/routes/users/controllers/users.ts
Normal 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' });
|
||||
}
|
||||
}
|
10
server/routes/users/index.ts
Normal file
10
server/routes/users/index.ts
Normal 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;
|
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
92
server/services/galleryService.ts
Normal file
92
server/services/galleryService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
81
server/services/s3Service.ts
Normal file
81
server/services/s3Service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
16
server/utils/permissions.ts
Normal file
16
server/utils/permissions.ts
Normal 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);
|
||||
};
|
@ -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"
|
||||
|
@ -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';
|
||||
|
40
src/components/BookmarkButton.tsx
Normal file
40
src/components/BookmarkButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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';
|
||||
|
16
src/components/Footer/DesignStudioLogo.tsx
Normal file
16
src/components/Footer/DesignStudioLogo.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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
|
@ -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';
|
||||
|
||||
|
@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Move, Pencil, Trash2 } from 'lucide-react';
|
||||
import { GalleryImage } from '../../types';
|
||||
|
||||
|
@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
interface ImageFormProps {
|
||||
|
@ -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';
|
||||
|
47
src/components/Header/CitySelector.tsx
Normal file
47
src/components/Header/CitySelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
11
src/components/Header/Logo.tsx
Normal file
11
src/components/Header/Logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
74
src/components/Header/MobileMenu.tsx
Normal file
74
src/components/Header/MobileMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
39
src/components/Header/Navigation.tsx
Normal file
39
src/components/Header/Navigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
40
src/components/Header/SearchBar.tsx
Normal file
40
src/components/Header/SearchBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
94
src/components/Header/index.tsx
Normal file
94
src/components/Header/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
45
src/components/ImageUpload/ImageDropzone.tsx
Normal file
45
src/components/ImageUpload/ImageDropzone.tsx
Normal 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>
|
||||
);
|
||||
}
|
81
src/components/ImageUpload/ImageUploader.tsx
Normal file
81
src/components/ImageUpload/ImageUploader.tsx
Normal 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>
|
||||
);
|
||||
}
|
36
src/components/ImageUpload/ResolutionSelect.tsx
Normal file
36
src/components/ImageUpload/ResolutionSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
56
src/components/ImageUpload/UploadProgress.tsx
Normal file
56
src/components/ImageUpload/UploadProgress.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface PaginationProps {
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
28
src/config/imageResolutions.ts
Normal file
28
src/config/imageResolutions.ts
Normal 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'
|
||||
}
|
||||
];
|
@ -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
94
src/hooks/useGallery.ts
Normal 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
|
||||
};
|
||||
}
|
@ -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';
|
||||
|
38
src/pages/BookmarksPage.tsx
Normal file
38
src/pages/BookmarksPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Header } from '../components/Header';
|
||||
import { FeaturedSection } from '../components/FeaturedSection';
|
||||
|
@ -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>
|
||||
|
@ -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';
|
||||
|
27
src/services/galleryService.ts
Normal file
27
src/services/galleryService.ts
Normal 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[];
|
||||
}
|
||||
};
|
32
src/services/imageService.ts
Normal file
32
src/services/imageService.ts
Normal 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;
|
||||
}
|
36
src/stores/bookmarkStore.ts
Normal file
36
src/stores/bookmarkStore.ts
Normal 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
21
src/types/image.ts
Normal 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;
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ export default defineConfig({
|
||||
exclude: ['@prisma/client', 'lucide-react'],
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000',
|
||||
|
Loading…
x
Reference in New Issue
Block a user