Работает загрузка изображения статьи в S3 и сохранение новой статьи.

This commit is contained in:
anibilag 2025-02-04 23:07:31 +03:00
parent 2a3f179f54
commit 5cc46da09d
68 changed files with 974 additions and 1813 deletions

548
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -8,12 +8,17 @@
"build": "vite build", "build": "vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"server": "node server/index.js" "server": "node server/index.js",
"prisma-seed": "npx prisma db seed",
"db:seed": "node prisma/seed.js"
},
"prisma": {
"seed": "npx ts-node --project tsconfig.node.json --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.525.0", "@aws-sdk/client-s3": "^3.525.0",
"@aws-sdk/s3-request-presigner": "^3.525.0", "@aws-sdk/s3-request-presigner": "^3.525.0",
"@prisma/client": "^5.10.2", "@prisma/client": "^6.2.1",
"@tiptap/pm": "^2.2.4", "@tiptap/pm": "^2.2.4",
"@tiptap/react": "^2.2.4", "@tiptap/react": "^2.2.4",
"@tiptap/starter-kit": "^2.2.4", "@tiptap/starter-kit": "^2.2.4",
@ -29,7 +34,6 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"sharp": "^0.33.2",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"winston": "^3.11.0", "winston": "^3.11.0",
"winston-daily-rotate-file": "^5.0.0", "winston-daily-rotate-file": "^5.0.0",
@ -37,22 +41,27 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.9.1", "@eslint/js": "^9.9.1",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^1.4.11", "@types/multer": "^1.4.11",
"@types/node": "^22.10.7",
"@types/react": "^18.3.5", "@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"@types/winston": "^2.4.4", "@types/winston": "^2.4.4",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18", "autoprefixer": "^10.4.20",
"eslint": "^9.9.1", "eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11", "eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0", "globals": "^15.9.0",
"postcss": "^8.4.35", "postcss": "^8.4.49",
"prisma": "^5.10.2", "prisma": "^6.2.1",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.17",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"typescript-eslint": "^8.3.0", "typescript-eslint": "^8.3.0",
"vite": "^5.4.2" "vite": "6.0.9"
} }
} }

View File

@ -0,0 +1,45 @@
/*
Warnings:
- You are about to drop the column `category` on the `Article` table. All the data in the column will be lost.
- Added the required column `categoryId` to the `Article` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Article" DROP COLUMN "category",
ADD COLUMN "categoryId" INTEGER NOT NULL;
-- CreateTable
CREATE TABLE "Category" (
"id" INTEGER NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GalleryImage" (
"id" TEXT NOT NULL,
"url" TEXT NOT NULL,
"caption" TEXT NOT NULL,
"alt" TEXT NOT NULL,
"width" INTEGER NOT NULL,
"height" INTEGER NOT NULL,
"size" INTEGER NOT NULL,
"format" TEXT NOT NULL,
"articleId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"order" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "GalleryImage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Category_name_key" ON "Category"("name");
-- AddForeignKey
ALTER TABLE "Article" ADD CONSTRAINT "Article_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GalleryImage" ADD CONSTRAINT "GalleryImage_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -24,7 +24,8 @@ model Article {
title String title String
excerpt String excerpt String
content String content String
category String category Category @relation(fields: [categoryId], references: [id])
categoryId Int
city String city String
coverImage String coverImage String
readTime Int readTime Int
@ -36,6 +37,12 @@ model Article {
gallery GalleryImage[] gallery GalleryImage[]
} }
model Category {
id Int @id
name String @unique
articles Article[]
}
model GalleryImage { model GalleryImage {
id String @id @default(uuid()) id String @id @default(uuid())
url String url String

16
prisma/seed.js Normal file
View File

@ -0,0 +1,16 @@
import { exec } from 'child_process';
import util from 'util';
const execPromise = util.promisify(exec);
async function runSeed() {
try {
await execPromise('npx ts-node prisma/seed.ts');
console.log('Seeding completed successfully.');
} catch (error) {
console.error('Error executing the seed script:', error);
process.exit(1);
}
}
runSeed();

36
prisma/seed.ts Normal file
View File

@ -0,0 +1,36 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
// Данные для заполнения
const categories = [
{ id: 1, name: "Film" },
{ id: 2, name: "Theater" },
{ id: 3, name: "Music" },
{ id: 4, name: "Sports" },
{ id: 5, name: "Art" },
{ id: 6, name: "Legends" },
{ id: 7, name: "Anniversaries" },
{ id: 8, name: "Memory" },
];
// Заполнение данных
for (const category of categories) {
await prisma.category.upsert({
where: { id: category.id },
update: {},
create: category,
});
}
console.log('Данные успешно добавлены!');
}
main()
.catch((e) => {
throw e;
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="800px" height="800px" viewBox="0 0 94 94" xml:space="preserve">
<g>
<g>
<path d="M47.051,37.59c5.247-0.017,9.426-4.23,9.407-9.489c-0.021-5.259-4.207-9.448-9.456-9.452
c-5.293-0.005-9.52,4.259-9.479,9.566C37.562,33.454,41.788,37.612,47.051,37.59z"/>
<path d="M89,0H5C2.239,0,0,2.238,0,5v84c0,2.762,2.239,5,5,5h84c2.762,0,5-2.238,5-5V5C94,2.238,91.762,0,89,0z M47.08,8.766
c10.699,0.027,19.289,8.781,19.236,19.602c-0.057,10.57-8.787,19.138-19.469,19.102c-10.576-0.036-19.248-8.803-19.188-19.396
C27.722,17.365,36.4,8.734,47.08,8.766z M68.753,55.072c-2.366,2.431-5.214,4.187-8.378,5.416
c-2.991,1.156-6.268,1.742-9.512,2.13c0.49,0.534,0.721,0.793,1.025,1.102c4.404,4.425,8.826,8.832,13.215,13.27
c1.494,1.511,1.81,3.386,0.985,5.145c-0.901,1.925-2.916,3.188-4.894,3.052c-1.252-0.088-2.228-0.711-3.094-1.582
c-3.324-3.345-6.711-6.627-9.965-10.031c-0.947-0.992-1.403-0.807-2.241,0.056c-3.343,3.442-6.738,6.831-10.155,10.2
c-1.535,1.514-3.36,1.785-5.143,0.922c-1.892-0.917-3.094-2.848-3.001-4.791c0.064-1.312,0.71-2.314,1.611-3.214
c4.356-4.351,8.702-8.713,13.05-13.072c0.289-0.288,0.557-0.597,0.976-1.045c-5.929-0.619-11.275-2.077-15.85-5.657
c-0.567-0.445-1.154-0.875-1.674-1.373c-2.002-1.924-2.203-4.125-0.618-6.396c1.354-1.942,3.632-2.464,5.997-1.349
c0.459,0.215,0.895,0.486,1.313,0.775c8.528,5.86,20.245,6.023,28.806,0.266c0.847-0.647,1.754-1.183,2.806-1.449
c2.045-0.525,3.947,0.224,5.045,2.012C70.314,51.496,70.297,53.488,68.753,55.072z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="800px"
height="800px" viewBox="0 0 512 512" xml:space="preserve">
<g id="7935ec95c421cee6d86eb22ecd128789">
<path style="display: inline;" d="M256.018,259.156c71.423,0,129.31-57.899,129.31-129.334C385.327,58.387,327.44,0.5,256.018,0.5
c-71.448,0-129.359,57.887-129.359,129.322C126.658,201.257,184.57,259.156,256.018,259.156z M256.018,66.196
c35.131,0,63.612,28.482,63.612,63.625c0,35.144-28.481,63.625-63.612,63.625c-35.168,0-63.638-28.481-63.638-63.625
C192.38,94.678,220.849,66.196,256.018,66.196z M405.075,274.938c-7.285-14.671-27.508-26.872-54.394-5.701
c-36.341,28.619-94.664,28.619-94.664,28.619s-58.361,0-94.702-28.619c-26.873-21.171-47.083-8.97-54.381,5.701
c-12.75,25.563,1.634,37.926,34.096,58.761c27.721,17.803,65.821,24.452,90.411,26.935l-20.535,20.535
c-28.918,28.905-56.826,56.838-76.201,76.213c-11.59,11.577-11.59,30.354,0,41.931l3.48,3.506c11.59,11.577,30.354,11.577,41.943,0
l76.201-76.214c28.943,28.919,56.851,56.839,76.225,76.214c11.59,11.577,30.354,11.577,41.943,0l3.48-3.506
c11.59-11.59,11.59-30.354,0-41.943l-76.201-76.2l-20.584-20.598c24.614-2.545,62.29-9.22,89.786-26.872
C403.441,312.863,417.801,300.5,405.075,274.938z">
</path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

37
public/images/ok-11.svg Normal file
View File

@ -0,0 +1,37 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
fill="black"
width="64"
height="64"
>
<!-- Ãîëîâà -->
<circle cx="50" cy="30" r="10" fill="black" />
<!-- Ðóêè -->
<path
d="M30 50 Q50 70, 70 50"
fill="none"
stroke="black"
stroke-width="8"
stroke-linecap="round"
/>
<!-- Òåëî -->
<path
d="M50 40 L50 60"
fill="none"
stroke="black"
stroke-width="8"
stroke-linecap="round"
/>
<!-- Íîãè -->
<path
d="M40 75 L50 60 L60 75"
fill="none"
stroke="black"
stroke-width="8"
stroke-linecap="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 609 B

33
public/images/ok.svg Normal file
View File

@ -0,0 +1,33 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
width="20"
height="20"
>
<!-- Âíóòðåííèé êðóã -->
<path
d="M47.051,37.59c5.247-0.017,9.426-4.23,9.407-9.489c-0.021-5.259-4.207-9.448-9.456-9.452
c-5.293-0.005-9.52,4.259-9.479,9.566C37.562,33.454,41.788,37.612,47.051,37.59z"
fill="#FF9900"
/>
<!-- Âíåøíèé êðóã -->
<path
d="M89,0H5C2.239,0,0,2.238,0,5v84c0,2.762,2.239,5,5,5h84c2.762,0,5-2.238,5-5V5C94,2.238,91.762,0,89,0z M47.08,8.766
c10.699,0.027,19.289,8.781,19.236,19.602c-0.057,10.57-8.787,19.138-19.469,19.102c-10.576-0.036-19.248-8.803-19.188-19.396
C27.722,17.365,36.4,8.734,47.08,8.766z"
fill="#FF6600"
/>
<!-- Ëèíèè è òî÷êè -->
<path
d="M68.753,55.072c-2.366,2.431-5.214,4.187-8.378,5.416c-2.991,1.156-6.268,1.742-9.512,2.13
c0.49,0.534,0.721,0.793,1.025,1.102c4.404,4.425,8.826,8.832,13.215,13.27c1.494,1.511,1.81,3.386,0.985,5.145
c-0.901,1.925-2.916,3.188-4.894,3.052c-1.252-0.088-2.228-0.711-3.094-1.582c-3.324-3.345-6.711-6.627-9.965-10.031
c-0.947-0.992-1.403-0.841..."
fill="#FF3300"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

View File

@ -1,26 +0,0 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import { PrismaClient } from '@prisma/client';
import authRoutes from './routes/auth.js';
import articleRoutes from './routes/articles';
import userRoutes from './routes/users.js';
dotenv.config();
const app = express();
const prisma = new PrismaClient();
app.use(cors());
app.use(express.json());
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/articles', articleRoutes);
app.use('/api/users', userRoutes);
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

View File

@ -1,45 +0,0 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
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, () => {
logger.info(`Server running on port ${PORT}`);
});
// Handle uncaught exceptions
process.on('uncaughtException', (error) => {
logger.error('Uncaught Exception:', error);
process.exit(1);
});
// Handle unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
process.exit(1);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,27 +0,0 @@
import express from 'express';
import { authService } from '../services/authService.js';
import { auth } from '../middleware/auth';
const router = express.Router();
// Login route
router.post('/login', async (req, res) => {
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' });
}
});
// Get current user route
router.get('/me', auth, async (req, res) => {
try {
res.json(req.user);
} catch {
res.status(500).json({ error: 'Server error' });
}
});
export default router;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,35 +0,0 @@
import express from 'express';
import { userService } from '../services/userService.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: Request, 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 (error) {
res.status(500).json({ error: 'Server error' });
}
});
router.put('/:id/permissions', auth, async (req: Request, 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 (error) {
res.status(500).json({ error: 'Server error' });
}
});
export default router;

View File

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

View File

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

View File

@ -1,95 +0,0 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
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) => {
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');
}
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;
}
},
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: {
email: string;
password: string;
displayName: string;
permissions: any;
}) => {
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
}
});
logger.info(`User created successfully: ${userData.email}`);
return user as User;
} catch (error) {
logger.error('User creation error:', error);
throw error;
}
}
};

View File

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

View File

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

View File

@ -1,44 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { User } from '../../src/types/auth';
const prisma = new PrismaClient();
export const userService = {
getUsers: async (): Promise<User[]> => {
try {
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
displayName: true,
permissions: true
}
});
return users as User[];
} catch (error) {
console.error('Error fetching users:', error);
throw new Error('Failed to fetch users');
}
},
updateUserPermissions: async (userId: string, permissions: User['permissions']): Promise<User> => {
try {
const updatedUser = await prisma.user.update({
where: { id: userId },
data: {
permissions: permissions as any
},
select: {
id: true,
email: true,
displayName: true,
permissions: true
}
});
return updatedUser as User;
} catch (error) {
console.error('Error updating user permissions:', error);
throw new Error('Failed to update user permissions');
}
}
};

View File

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

View File

@ -1,6 +1,7 @@
import { Clock, ThumbsUp, MapPin } from 'lucide-react'; import { Clock, ThumbsUp, MapPin } from 'lucide-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Article } from '../types'; import { Article } from '../types';
import { getCategoryName } from '../utils/categories';
import MinutesWord from './MinutesWord.tsx'; import MinutesWord from './MinutesWord.tsx';
interface ArticleCardProps { interface ArticleCardProps {
@ -9,6 +10,8 @@ interface ArticleCardProps {
} }
export function ArticleCard({ article, featured = false }: ArticleCardProps) { export function ArticleCard({ article, featured = false }: ArticleCardProps) {
const categoryName = getCategoryName(article.categoryId);
return ( return (
<article className={`overflow-hidden rounded-lg shadow-lg transition-transform hover:scale-[1.02] ${ <article className={`overflow-hidden rounded-lg shadow-lg transition-transform hover:scale-[1.02] ${
featured ? 'col-span-2 row-span-2' : '' featured ? 'col-span-2 row-span-2' : ''
@ -21,7 +24,7 @@ export function ArticleCard({ article, featured = false }: ArticleCardProps) {
/> />
<div className="absolute top-4 left-4 flex gap-2"> <div className="absolute top-4 left-4 flex gap-2">
<span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium"> <span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium">
{article.category} {categoryName}
</span> </span>
<span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium flex items-center"> <span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium flex items-center">
<MapPin size={14} className="mr-1" /> <MapPin size={14} className="mr-1" />

View File

@ -31,13 +31,13 @@ export function AuthGuard({ children, requiredPermissions }: AuthGuardProps) {
const { category, action } = requiredPermissions; const { category, action } = requiredPermissions;
if (!user.permissions.isAdmin) { if (!user.permissions.isAdmin) {
if (category && !user.permissions.categories[category]?.[action]) { if (category && !user.permissions.categories[category.name]?.[action]) {
return ( return (
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2> <h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
<p className="text-gray-600"> <p className="text-gray-600">
You don't have permission to {action} articles in the {category} category. You don't have permission to {action} articles in the {category.name} category.
</p> </p>
</div> </div>
</div> </div>

View File

@ -3,30 +3,32 @@ import { useLocation, useSearchParams } from 'react-router-dom';
import { ArticleCard } from './ArticleCard'; import { ArticleCard } from './ArticleCard';
import { Pagination } from './Pagination'; import { Pagination } from './Pagination';
import { articles } from '../data/mock'; import { articles } from '../data/mock';
import { getCategoryId } from '../utils/categories';
import { CategoryName } from '../types';
const ARTICLES_PER_PAGE = 6; const ARTICLES_PER_PAGE = 6;
export function FeaturedSection() { export function FeaturedSection() {
const location = useLocation(); const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get('category'); const categoryParam = searchParams.get('category') as CategoryName | null;
const city = searchParams.get('city'); const city = searchParams.get('city');
const currentPage = parseInt(searchParams.get('page') || '1', 10); const currentPage = parseInt(searchParams.get('page') || '1', 10);
const filteredArticles = useMemo(() => { const filteredArticles = useMemo(() => {
return articles.filter(article => { return articles.filter(article => {
if (category && city) { if (categoryParam && city) {
return article.category === category && article.city === city; return article.categoryId === getCategoryId(categoryParam) && article.city === city;
} }
if (category) { if (categoryParam) {
return article.category === category; return article.categoryId === getCategoryId(categoryParam);
} }
if (city) { if (city) {
return article.city === city; return article.city === city;
} }
return true; return true;
}); });
}, [category, city]); }, [categoryParam, city]);
const totalPages = Math.ceil(filteredArticles.length / ARTICLES_PER_PAGE); const totalPages = Math.ceil(filteredArticles.length / ARTICLES_PER_PAGE);
@ -61,7 +63,7 @@ export function FeaturedSection() {
<div className="flex justify-between items-center mb-8"> <div className="flex justify-between items-center mb-8">
<h2 className="text-3xl font-bold text-gray-900"> <h2 className="text-3xl font-bold text-gray-900">
{city ? `${city} ` : ''} {city ? `${city} ` : ''}
{category ? `${category} Статьи` : 'Тематические статьи'} {categoryParam ? `${categoryParam} Статьи` : 'Тематические статьи'}
</h2> </h2>
<p className="text-gray-600"> <p className="text-gray-600">
Показано {Math.min(currentPage * ARTICLES_PER_PAGE, filteredArticles.length)} из {filteredArticles.length} статей Показано {Math.min(currentPage * ARTICLES_PER_PAGE, filteredArticles.length)} из {filteredArticles.length} статей
@ -73,7 +75,7 @@ export function FeaturedSection() {
<ArticleCard <ArticleCard
key={article.id} key={article.id}
article={article} article={article}
featured={currentPage === 1 && index === 0 && !category && !city} featured={currentPage === 1 && index === 0 && !categoryParam && !city}
/> />
))} ))}
</div> </div>

View File

@ -9,15 +9,15 @@ export function Footer() {
<div className="grid grid-cols-1 md:grid-cols-4 gap-8"> <div className="grid grid-cols-1 md:grid-cols-4 gap-8">
{/* About Section */} {/* About Section */}
<div className="col-span-2"> <div className="col-span-2">
<h2 className="text-xl font-bold text-white mb-4">About CultureScope</h2> <h2 className="text-xl font-bold text-white mb-4">О нас</h2>
<p className="text-gray-400 mb-4"> <p className="text-gray-400 mb-4">
CultureScope is your premier destination for arts and culture coverage. Культура двух столиц - это главное место, где вы можете найти информацию об искусстве и культуре.
Founded in 2024, we bring you the latest in film, theater, music, sports, Основав портал в 2015 году, мы предлагаем вам самые свежие новости о кино,
and cultural stories from around the globe. театре, музыке, спорте и культуре Москвы и Санкт-Петербурга.
</p> </p>
<div className="flex space-x-4"> <div className="flex space-x-4">
<a href="https://twitter.com" className="hover:text-white transition-colors"> <a href="https://twitter.com" className="hover:text-white transition-colors">
<Twitter size={20} /> <img src="/images/ok-11.svg" alt="" width="20" height="20"/>
</a> </a>
<a href="https://facebook.com" className="hover:text-white transition-colors"> <a href="https://facebook.com" className="hover:text-white transition-colors">
<Facebook size={20}/> <Facebook size={20}/>
@ -30,46 +30,46 @@ export function Footer() {
{/* Quick Links */} {/* Quick Links */}
<div> <div>
<h3 className="text-lg font-semibold text-white mb-4">Quick Links</h3> <h3 className="text-lg font-semibold text-white mb-4">Быстрые ссылки</h3>
<ul className="space-y-2"> <ul className="space-y-2">
<li> <li>
<Link to="/?category=Film" className="hover:text-white transition-colors"> <Link to="/?category=Film" className="hover:text-white transition-colors">
Film Кино
</Link> </Link>
</li> </li>
<li> <li>
<Link to="/?category=Theater" className="hover:text-white transition-colors"> <Link to="/?category=Theater" className="hover:text-white transition-colors">
Theater Театр
</Link> </Link>
</li> </li>
<li> <li>
<Link to="/?category=Music" className="hover:text-white transition-colors"> <Link to="/?category=Music" className="hover:text-white transition-colors">
Music Музыка
</Link> </Link>
</li> </li>
<li> <li>
<Link to="/?category=Sports" className="hover:text-white transition-colors"> <Link to="/?category=Sports" className="hover:text-white transition-colors">
Sports Спорт
</Link> </Link>
</li> </li>
<li> <li>
<Link to="/?category=Art" className="hover:text-white transition-colors"> <Link to="/?category=Art" className="hover:text-white transition-colors">
Art Исскуство
</Link> </Link>
</li> </li>
<li> <li>
<Link to="/?category=Legends" className="hover:text-white transition-colors"> <Link to="/?category=Legends" className="hover:text-white transition-colors">
Legends Легенды
</Link> </Link>
</li> </li>
<li> <li>
<Link to="/?category=Anniversaries" className="hover:text-white transition-colors"> <Link to="/?category=Anniversaries" className="hover:text-white transition-colors">
Anniversaries Юбилеи
</Link> </Link>
</li> </li>
<li> <li>
<Link to="/?category=Memory" className="hover:text-white transition-colors"> <Link to="/?category=Memory" className="hover:text-white transition-colors">
Memory Память
</Link> </Link>
</li> </li>
</ul> </ul>
@ -77,12 +77,12 @@ export function Footer() {
{/* Contact Info */} {/* Contact Info */}
<div> <div>
<h3 className="text-lg font-semibold text-white mb-4">Contact Us</h3> <h3 className="text-lg font-semibold text-white mb-4">Контакты</h3>
<ul className="space-y-4"> <ul className="space-y-4">
<li className="flex items-center"> <li className="flex items-center">
<Mail size={18} className="mr-2" /> <Mail size={18} className="mr-2" />
<a href="mailto:contact@culturescope.com" className="hover:text-white transition-colors"> <a href="mailto:contact@culturescope.com" className="hover:text-white transition-colors">
contact@culturescope.com izolkin@yandex.ru
</a> </a>
</li> </li>
<li className="flex items-center"> <li className="flex items-center">
@ -93,12 +93,12 @@ export function Footer() {
</li> </li>
</ul> </ul>
<h3 className="text-lg font-semibold text-white mt-6 mb-4">Our Partners</h3> <h3 className="text-lg font-semibold text-white mt-6 mb-4">Наши партнеры</h3>
<ul className="space-y-2"> <ul className="space-y-2">
<li className="flex items-center"> <li className="flex items-center">
<ExternalLink size={16} className="mr-2" /> <ExternalLink size={16} className="mr-2" />
<a href="https://metropolitan.museum" className="hover:text-white transition-colors"> <a href="https://excurspb.ru" className="hover:text-white transition-colors">
Metropolitan Museum Туроператор «Прогулки»
</a> </a>
</li> </li>
<li className="flex items-center"> <li className="flex items-center">
@ -121,7 +121,7 @@ export function Footer() {
<div className="flex flex-col md:flex-row justify-between items-center"> <div className="flex flex-col md:flex-row justify-between items-center">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
© {new Date().getFullYear()} CultureScope. All rights reserved. © {new Date().getFullYear()} Культура двух столиц. Все права защищены.
</p> </p>
<span className="text-gray-600"></span> <span className="text-gray-600"></span>
<DesignStudioLogo /> <DesignStudioLogo />

View File

@ -49,11 +49,11 @@ export function GalleryManager({ images, onChange }: GalleryManagerProps) {
<div className="space-y-6"> <div className="space-y-6">
{/* Add New Image */} {/* Add New Image */}
<div className="border rounded-lg p-4 space-y-4"> <div className="border rounded-lg p-4 space-y-4">
<h3 className="text-lg font-medium text-gray-900">Add New Image</h3> <h3 className="text-lg font-medium text-gray-900">Добавить новое фото</h3>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label htmlFor="imageUrl" className="block text-sm font-medium text-gray-700"> <label htmlFor="imageUrl" className="block text-sm font-medium text-gray-700">
Image URL URL изображения
</label> </label>
<input <input
type="url" type="url"
@ -66,7 +66,7 @@ export function GalleryManager({ images, onChange }: GalleryManagerProps) {
</div> </div>
<div> <div>
<label htmlFor="imageCaption" className="block text-sm font-medium text-gray-700"> <label htmlFor="imageCaption" className="block text-sm font-medium text-gray-700">
Caption Заголовок
</label> </label>
<input <input
type="text" type="text"
@ -78,7 +78,7 @@ export function GalleryManager({ images, onChange }: GalleryManagerProps) {
</div> </div>
<div> <div>
<label htmlFor="imageAlt" className="block text-sm font-medium text-gray-700"> <label htmlFor="imageAlt" className="block text-sm font-medium text-gray-700">
Alt Text Текст при наведении
</label> </label>
<input <input
type="text" type="text"
@ -94,14 +94,14 @@ export function GalleryManager({ images, onChange }: GalleryManagerProps) {
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
> >
<Plus size={16} className="mr-2" /> <Plus size={16} className="mr-2" />
Add Image Добавить фото
</button> </button>
</div> </div>
</div> </div>
{/* Gallery Preview */} {/* Gallery Preview */}
<div className="border rounded-lg p-4"> <div className="border rounded-lg p-4">
<h3 className="text-lg font-medium text-gray-900 mb-4">Gallery Images</h3> <h3 className="text-lg font-medium text-gray-900 mb-4">Состав галереи</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{images.map((image, index) => ( {images.map((image, index) => (
<div <div

View File

@ -0,0 +1,116 @@
import React, { useRef } from 'react';
import { ImagePlus, X } from 'lucide-react';
import axios from 'axios';
import { imageResolutions } from '../../config/imageResolutions';
interface CoverImageUploadProps {
coverImage: string;
onImageUpload: (url: string) => void;
onError: (error: string) => void;
}
export function CoverImageUpload({ coverImage, onImageUpload, onError }: CoverImageUploadProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
// Проверка разрешения изображения
const resolution = imageResolutions.find(r => r.id === 'large');
if (!resolution)
throw new Error('Invalid resolution');
const formData = new FormData();
formData.append('file', file);
formData.append('resolutionId', resolution.id);
formData.append('folder', 'articles/1');
// Получение токена из локального хранилища
const token = localStorage.getItem('token');
// Отправка запроса на сервер
const response = await axios.post('/api/images/upload-url', formData, {
headers: {
'Content-Type': 'multipart/form-data',
'Authorization': `Bearer ${token}`, // Передача токена аутентификации
},
});
if (response.data?.fileUrl) {
onImageUpload(response.data.fileUrl); // Передача URL загруженного изображения
}
} catch (error) {
onError('Failed to upload image. Please try again.');
console.error('Upload error:', error);
}
// Сброс значения input для повторного выбора того же файла
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleClearImage = () => {
onImageUpload('');
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<div>
<label htmlFor="coverImage" className="block text-sm font-medium text-gray-700">
Изображение
</label>
<div className="mt-1 space-y-4">
{coverImage && (
<div className="relative w-48 h-32 rounded-lg overflow-hidden group">
<img
src={coverImage}
alt="Cover preview"
className="w-full h-full object-cover"
/>
<button
type="button"
onClick={handleClearImage}
className="absolute top-2 right-2 p-1 bg-white rounded-full shadow-md opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={16} className="text-gray-600" />
</button>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-all" />
</div>
)}
<div className="flex rounded-md shadow-sm">
<input
type="text"
id="coverImage"
value={coverImage}
readOnly
className="flex-1 rounded-l-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
placeholder="URL изображения будет здесь после загрузки"
/>
<label
htmlFor="coverImageUpload"
className="cursor-pointer inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-r-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<ImagePlus size={18} className="mr-2" />
Выбор
</label>
<input
ref={fileInputRef}
type="file"
id="coverImageUpload"
className="hidden"
accept="image/*"
onChange={handleFileSelect}
/>
</div>
<p className="text-sm text-gray-500">
Рекомендуемый размер: 1920x1080px. Поддерживаются PNG, JPG, JPEG или WebP до 10MB.
</p>
</div>
</div>
);
}

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { ImageDropzone } from './ImageDropzone'; import { ImageDropzone } from './ImageDropzone';
import { ResolutionSelect } from './ResolutionSelect'; import { ResolutionSelect } from './ResolutionSelect';
import { UploadProgress } from './UploadProgress'; import { UploadProgress } from './UploadProgress';

View File

@ -1,15 +1,15 @@
import { Article, Author } from '../types'; import {Article, Author, CategoryName} from '../types';
export const authors: Author[] = [ export const authors: Author[] = [
{ {
id: '1', id: '1',
name: 'Elena Martinez', name: 'Елена Маркова',
avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&q=80&w=150&h=150', avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&q=80&w=150&h=150',
bio: 'Cultural critic and art historian based in Barcelona', bio: 'Cultural critic and art historian based in Barcelona',
}, },
{ {
id: '2', id: '2',
name: 'James Chen', name: 'Илья Золкин',
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&q=80&w=150&h=150', avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&q=80&w=150&h=150',
bio: 'Music journalist and classical pianist', bio: 'Music journalist and classical pianist',
}, },
@ -21,7 +21,7 @@ export const articles: Article[] = [
title: 'The Renaissance of Independent Theater', title: 'The Renaissance of Independent Theater',
excerpt: 'How small theater companies are revolutionizing modern storytelling through innovative approaches and community engagement.', excerpt: 'How small theater companies are revolutionizing modern storytelling through innovative approaches and community engagement.',
content: 'In the heart of urban artistic communities, independent theater companies are crafting innovative narratives that challenge traditional storytelling methods.', content: 'In the heart of urban artistic communities, independent theater companies are crafting innovative narratives that challenge traditional storytelling methods.',
category: 'Theater', categoryId: 2, // Theater
city: 'New York', city: 'New York',
author: authors[0], author: authors[0],
coverImage: 'https://images.unsplash.com/photo-1503095396549-807759245b35?auto=format&fit=crop&q=80&w=2070', coverImage: 'https://images.unsplash.com/photo-1503095396549-807759245b35?auto=format&fit=crop&q=80&w=2070',
@ -56,7 +56,7 @@ export const articles: Article[] = [
title: 'Evolution of Digital Art Museums', title: 'Evolution of Digital Art Museums',
excerpt: 'Exploring the intersection of technology and traditional art spaces in the modern digital age.', excerpt: 'Exploring the intersection of technology and traditional art spaces in the modern digital age.',
content: 'As we venture further into the digital age, museums are adapting to new ways of presenting art that blend traditional curation with cutting-edge technology.', content: 'As we venture further into the digital age, museums are adapting to new ways of presenting art that blend traditional curation with cutting-edge technology.',
category: 'Art', categoryId: 5, // Art
city: 'London', city: 'London',
author: authors[1], author: authors[1],
coverImage: 'https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8b?auto=format&fit=crop&q=80&w=2070', coverImage: 'https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8b?auto=format&fit=crop&q=80&w=2070',
@ -72,7 +72,7 @@ export const articles: Article[] = [
title: 'The Future of Classical Music', title: 'The Future of Classical Music',
excerpt: 'Contemporary composers bridging the gap between traditional and modern musical expressions.', excerpt: 'Contemporary composers bridging the gap between traditional and modern musical expressions.',
content: 'Classical music is experiencing a remarkable transformation as new composers blend traditional orchestral elements with contemporary influences.', content: 'Classical music is experiencing a remarkable transformation as new composers blend traditional orchestral elements with contemporary influences.',
category: 'Music', categoryId: 3, // Music
city: 'London', city: 'London',
author: authors[1], author: authors[1],
coverImage: 'https://images.unsplash.com/photo-1520523839897-bd0b52f945a0?auto=format&fit=crop&q=80&w=2070', coverImage: 'https://images.unsplash.com/photo-1520523839897-bd0b52f945a0?auto=format&fit=crop&q=80&w=2070',
@ -88,7 +88,7 @@ export const articles: Article[] = [
title: 'Modern Literature in the Digital Age', title: 'Modern Literature in the Digital Age',
excerpt: 'How e-books and digital platforms are changing the way we consume and create literature.', 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.', content: 'The digital revolution has transformed the literary landscape, offering new ways for authors to reach readers and for stories to be told.',
category: 'Art', categoryId: 5, // Art
city: 'New York', city: 'New York',
author: authors[0], author: authors[0],
coverImage: 'https://images.unsplash.com/photo-1524995997946-a1c2e315a42f?auto=format&fit=crop&q=80&w=2070', coverImage: 'https://images.unsplash.com/photo-1524995997946-a1c2e315a42f?auto=format&fit=crop&q=80&w=2070',
@ -104,7 +104,7 @@ export const articles: Article[] = [
title: 'The Rise of Immersive Art Installations', title: 'The Rise of Immersive Art Installations',
excerpt: 'How interactive and immersive art is transforming gallery spaces worldwide.', excerpt: 'How interactive and immersive art is transforming gallery spaces worldwide.',
content: 'Interactive art installations are revolutionizing the way we experience and engage with contemporary art.', content: 'Interactive art installations are revolutionizing the way we experience and engage with contemporary art.',
category: 'Art', categoryId: 5, // Art
city: 'New York', city: 'New York',
author: authors[0], author: authors[0],
coverImage: 'https://images.unsplash.com/photo-1547826039-bfc35e0f1ea8?auto=format&fit=crop&q=80&w=2070', coverImage: 'https://images.unsplash.com/photo-1547826039-bfc35e0f1ea8?auto=format&fit=crop&q=80&w=2070',
@ -120,7 +120,7 @@ export const articles: Article[] = [
title: 'Film Festivals in the Post-Pandemic Era', title: 'Film Festivals in the Post-Pandemic Era',
excerpt: 'How film festivals are adapting to hybrid formats and reaching wider audiences.', excerpt: 'How film festivals are adapting to hybrid formats and reaching wider audiences.',
content: 'Film festivals are embracing digital platforms while maintaining the magic of in-person screenings.', content: 'Film festivals are embracing digital platforms while maintaining the magic of in-person screenings.',
category: 'Film', categoryId: 1, // Film
city: 'London', city: 'London',
author: authors[1], author: authors[1],
coverImage: 'https://images.unsplash.com/photo-1478720568477-152d9b164e26?auto=format&fit=crop&q=80&w=2070', coverImage: 'https://images.unsplash.com/photo-1478720568477-152d9b164e26?auto=format&fit=crop&q=80&w=2070',
@ -136,10 +136,10 @@ export const articles: Article[] = [
title: 'Street Art: From Vandalism to Validation', title: 'Street Art: From Vandalism to Validation',
excerpt: 'The evolution of street art and its acceptance in the contemporary art world.', excerpt: 'The evolution of street art and its acceptance in the contemporary art world.',
content: 'Street art has transformed from a controversial form of expression to a celebrated art movement.', content: 'Street art has transformed from a controversial form of expression to a celebrated art movement.',
category: 'Art', categoryId: 5, // Art
city: 'New York', city: 'New York',
author: authors[0], author: authors[0],
coverImage: 'https://images.unsplash.com/photo-1499781350541-7783f6c6a0c8?auto=format&fit=crop&q=80&w=2070', coverImage: 'https://images.unsplash.com/photo-1524995997946-a1c2e315a42f?auto=format&fit=crop&q=80&w=2070',
gallery: [] , gallery: [] ,
publishedAt: '2024-03-09T10:00:00Z', publishedAt: '2024-03-09T10:00:00Z',
readTime: 6, readTime: 6,
@ -152,7 +152,7 @@ export const articles: Article[] = [
title: 'The New Wave of Theater Technology', title: 'The New Wave of Theater Technology',
excerpt: 'How digital innovations are enhancing live theater performances.', excerpt: 'How digital innovations are enhancing live theater performances.',
content: 'Modern theater productions are incorporating cutting-edge technology to create immersive experiences.', content: 'Modern theater productions are incorporating cutting-edge technology to create immersive experiences.',
category: 'Theater', categoryId: 2, // Theater
city: 'London', city: 'London',
author: authors[1], author: authors[1],
coverImage: 'https://images.unsplash.com/photo-1507676184212-d03ab07a01bf?auto=format&fit=crop&q=80&w=2070', coverImage: 'https://images.unsplash.com/photo-1507676184212-d03ab07a01bf?auto=format&fit=crop&q=80&w=2070',

View File

@ -1,7 +1,17 @@
import { User } from '../types/auth'; import { User } from '../types/auth';
import { Category, City } from '../types'; import { Category, City } from '../types';
const categories: Category[] = ['Film', 'Theater', 'Music', 'Sports', 'Art', 'Legends', 'Anniversaries', 'Memory']; const categories: Category[] = [
{id : 1, name : 'Film'},
{id : 2, name : 'Theater'},
{id : 3, name : 'Music'},
{id : 4, name : 'Sports'},
{id : 5, name : 'Art'},
{id : 6, name : 'Legends'},
{id : 7, name : 'Anniversaries'},
{id : 8, name : 'Memory'}
];
const cities: City[] = ['New York', 'London']; const cities: City[] = ['New York', 'London'];
export const mockUsers: User[] = [ export const mockUsers: User[] = [
@ -12,7 +22,7 @@ export const mockUsers: User[] = [
permissions: { permissions: {
categories: categories.reduce((acc, category) => ({ categories: categories.reduce((acc, category) => ({
...acc, ...acc,
[category]: { create: true, edit: true, delete: true } [category.id]: { create: true, edit: true, delete: true }
}), {}), }), {}),
cities: cities, cities: cities,
isAdmin: true isAdmin: true

View File

@ -1,4 +1,4 @@
import { Category } from '../types'; import { CategoryName } from '../types';
const backgroundImages = { const backgroundImages = {
Film: '/images/film-bg.avif?auto=format&fit=crop&q=80&w=2070', Film: '/images/film-bg.avif?auto=format&fit=crop&q=80&w=2070',
@ -12,6 +12,6 @@ const backgroundImages = {
default: 'https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8b?auto=format&fit=crop&q=80&w=2070' default: 'https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8b?auto=format&fit=crop&q=80&w=2070'
}; };
export function useBackgroundImage(category?: Category | null) { export function useBackgroundImage(name?: CategoryName | null) {
return category ? backgroundImages[category] : backgroundImages.default; return name ? backgroundImages[name] : backgroundImages.default;
} }

View File

@ -39,8 +39,8 @@ export function useUserManagement() {
...selectedUser.permissions, ...selectedUser.permissions,
categories: { categories: {
...selectedUser.permissions.categories, ...selectedUser.permissions.categories,
[category]: { [category.name]: {
...selectedUser.permissions.categories[category], ...selectedUser.permissions.categories[category.name],
[action]: value, [action]: value,
}, },
}, },

View File

@ -1,20 +1,32 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo, useEffect } from 'react';
import axios from "axios";
import { useEditor, EditorContent } from '@tiptap/react'; import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import { Header } from '../components/Header'; import { Header } from '../components/Header';
import { GalleryManager } from '../components/GalleryManager'; import { GalleryManager } from '../components/GalleryManager';
import { Category, City, GalleryImage } from '../types'; import { CoverImageUpload } from '../components/ImageUpload/CoverImageUpload';
import { ImagePlus, Bold, Italic, List, ListOrdered, Quote, Pencil, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'; import {Category, City, Article, GalleryImage, CategoryMap} from '../types';
import { Bold, Italic, List, ListOrdered, Quote, Pencil, Trash2, ChevronLeft, ChevronRight } from 'lucide-react';
import { articles } from '../data/mock'; import { articles } from '../data/mock';
const categories: Category[] = ['Film', 'Theater', 'Music', 'Sports', 'Art', 'Legends', 'Anniversaries', 'Memory']; //const categories: Category[] = ['Film', 'Theater', 'Music', 'Sports', 'Art', 'Legends', 'Anniversaries', 'Memory'];
const categories: Category[] = ['Кино', 'Театр', 'Музыка', 'Спорт', 'Искусство', 'Легенды', 'Юбилеи', 'Память'];
/*
const categorieIds: Category[] = CategoryNames.map((name, index) => ({
id: index + 1,
name,
}));
*/
const cities: City[] = ['New York', 'London']; const cities: City[] = ['New York', 'London'];
const ARTICLES_PER_PAGE = 5; const ARTICLES_PER_PAGE = 5;
export function AdminPage() { export function AdminPage() {
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [excerpt, setExcerpt] = useState(''); const [excerpt, setExcerpt] = useState('');
const [category, setCategory] = useState<Category>('Art'); const [category, setCategory] = useState<Category>('Искусство');
const [city, setCity] = useState<City>('New York'); const [city, setCity] = useState<City>('New York');
const [coverImage, setCoverImage] = useState(''); const [coverImage, setCoverImage] = useState('');
const [readTime, setReadTime] = useState(5); const [readTime, setReadTime] = useState(5);
@ -25,6 +37,7 @@ export function AdminPage() {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [filterCategory, setFilterCategory] = useState<Category | ''>(''); const [filterCategory, setFilterCategory] = useState<Category | ''>('');
const [filterCity, setFilterCity] = useState<City | ''>(''); const [filterCity, setFilterCity] = useState<City | ''>('');
const [error, setError] = useState<string | null>(null);
const editor = useEditor({ const editor = useEditor({
extensions: [StarterKit], extensions: [StarterKit],
@ -36,6 +49,25 @@ export function AdminPage() {
}, },
}); });
/*
useEffect(() => {
async function fetchArticles() {
try {
const response = await axios.get('/api/articles', {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
setArticlesList(response.data);
} catch (err) {
setError('Не удалось загрузить статьи');
console.error(err);
}
}
fetchArticles();
}, []);
*/
const filteredArticles = useMemo(() => { const filteredArticles = useMemo(() => {
return articlesList.filter(article => { return articlesList.filter(article => {
if (filterCategory && filterCity) { if (filterCategory && filterCity) {
@ -82,12 +114,19 @@ export function AdminPage() {
setShowDeleteModal(null); setShowDeleteModal(null);
}; };
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const categoryId : number = CategoryMap[category];
if (!categoryId) {
console.error('Invalid category name.');
return;
}
const articleData = { const articleData = {
title, title,
excerpt, excerpt,
category, categoryId,
city, city,
coverImage, coverImage,
readTime, readTime,
@ -105,6 +144,14 @@ export function AdminPage() {
); );
setEditingId(null); setEditingId(null);
} else { } else {
// Создание новой статьи
const response = await axios.post('/api/articles', articleData, {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
setArticlesList(prev => [...prev, response.data]);
// In a real app, this would send data to an API // In a real app, this would send data to an API
console.log('Creating new article:', articleData); console.log('Creating new article:', articleData);
} }
@ -126,13 +173,19 @@ export function AdminPage() {
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="bg-white rounded-lg shadow-sm p-6 mb-8"> <div className="bg-white rounded-lg shadow-sm p-6 mb-8">
<h1 className="text-2xl font-bold text-gray-900 mb-6"> <h1 className="text-2xl font-bold text-gray-900 mb-6">
{editingId ? 'Edit Article' : 'Create New Article'} {editingId ? 'Редактировать статью' : 'Создать новую статью'}
</h1> </h1>
{error && (
<div className="mb-6 bg-red-50 text-red-700 p-4 rounded-md">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div> <div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700"> <label htmlFor="title" className="block text-sm font-medium text-gray-700">
Title Заголовок
</label> </label>
<input <input
type="text" type="text"
@ -146,7 +199,7 @@ export function AdminPage() {
<div> <div>
<label htmlFor="excerpt" className="block text-sm font-medium text-gray-700"> <label htmlFor="excerpt" className="block text-sm font-medium text-gray-700">
Excerpt Краткое описание
</label> </label>
<textarea <textarea
id="excerpt" id="excerpt"
@ -161,7 +214,7 @@ export function AdminPage() {
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
<div> <div>
<label htmlFor="category" className="block text-sm font-medium text-gray-700"> <label htmlFor="category" className="block text-sm font-medium text-gray-700">
Category Категория
</label> </label>
<select <select
id="category" id="category"
@ -179,7 +232,7 @@ export function AdminPage() {
<div> <div>
<label htmlFor="city" className="block text-sm font-medium text-gray-700"> <label htmlFor="city" className="block text-sm font-medium text-gray-700">
City Город
</label> </label>
<select <select
id="city" id="city"
@ -197,7 +250,7 @@ export function AdminPage() {
<div> <div>
<label htmlFor="readTime" className="block text-sm font-medium text-gray-700"> <label htmlFor="readTime" className="block text-sm font-medium text-gray-700">
Read Time (minutes) Время чтения (минуты)
</label> </label>
<input <input
type="number" type="number"
@ -210,33 +263,15 @@ export function AdminPage() {
</div> </div>
</div> </div>
<div> <CoverImageUpload
<label htmlFor="coverImage" className="block text-sm font-medium text-gray-700"> coverImage={coverImage}
Cover Image URL onImageUpload={setCoverImage}
</label> onError={setError}
<div className="mt-1 flex rounded-md shadow-sm">
<input
type="url"
id="coverImage"
value={coverImage}
onChange={(e) => setCoverImage(e.target.value)}
className="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
placeholder="https://example.com/image.jpg"
required
/> />
<button
type="button"
className="ml-3 inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<ImagePlus size={18} className="mr-2" />
Browse
</button>
</div>
</div>
<div> <div>
<label className="block text-sm font-medium text-gray-700"> <label className="block text-sm font-medium text-gray-700">
Content Статья
</label> </label>
<div className="border rounded-lg overflow-hidden"> <div className="border rounded-lg overflow-hidden">
<div className="border-b bg-gray-50 px-4 py-2"> <div className="border-b bg-gray-50 px-4 py-2">
@ -296,7 +331,7 @@ export function AdminPage() {
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-4"> <label className="block text-sm font-medium text-gray-700 mb-4">
Photo Gallery Фото Галерея
</label> </label>
<GalleryManager <GalleryManager
images={gallery} images={gallery}
@ -312,7 +347,7 @@ export function AdminPage() {
setEditingId(null); setEditingId(null);
setTitle(''); setTitle('');
setExcerpt(''); setExcerpt('');
setCategory('Art'); setCategory('Искусство');
setCity('New York'); setCity('New York');
setCoverImage(''); setCoverImage('');
setReadTime(5); setReadTime(5);
@ -321,14 +356,14 @@ export function AdminPage() {
}} }}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
> >
Cancel Отмена
</button> </button>
)} )}
<button <button
type="submit" type="submit"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
> >
{editingId ? 'Update Article' : 'Publish Article'} {editingId ? 'Изменить статью' : 'Опубликовать статью'}
</button> </button>
</div> </div>
</form> </form>
@ -338,7 +373,7 @@ export function AdminPage() {
<div className="bg-white rounded-lg shadow-sm"> <div className="bg-white rounded-lg shadow-sm">
<div className="px-6 py-4 border-b border-gray-200"> <div className="px-6 py-4 border-b border-gray-200">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 className="text-lg font-medium text-gray-900">Published Articles</h2> <h2 className="text-lg font-medium text-gray-900">Опубликованные статьи</h2>
<div className="flex gap-4"> <div className="flex gap-4">
<select <select
value={filterCategory} value={filterCategory}
@ -348,7 +383,7 @@ export function AdminPage() {
}} }}
className="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm" className="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
> >
<option value="">All Categories</option> <option value="">Все категории</option>
{categories.map((cat) => ( {categories.map((cat) => (
<option key={cat} value={cat}> <option key={cat} value={cat}>
{cat} {cat}
@ -363,7 +398,7 @@ export function AdminPage() {
}} }}
className="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm" className="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
> >
<option value="">All Cities</option> <option value="">Все столицы</option>
{cities.map((c) => ( {cities.map((c) => (
<option key={c} value={c}> <option key={c} value={c}>
{c} {c}

View File

@ -133,7 +133,7 @@ export function ArticlePage() {
{/* Photo Gallery */} {/* Photo Gallery */}
{articleData.gallery && articleData.gallery.length > 0 && ( {articleData.gallery && articleData.gallery.length > 0 && (
<div className="mb-8"> <div className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Photo Gallery</h2> <h2 className="text-2xl font-bold text-gray-900 mb-4">Фото галерея</h2>
<PhotoGallery images={articleData.gallery} /> <PhotoGallery images={articleData.gallery} />
</div> </div>
)} )}

View File

@ -2,19 +2,21 @@ import { useSearchParams } from 'react-router-dom';
import { Header } from '../components/Header'; import { Header } from '../components/Header';
import { FeaturedSection } from '../components/FeaturedSection'; import { FeaturedSection } from '../components/FeaturedSection';
import { useBackgroundImage } from '../hooks/useBackgroundImage'; import { useBackgroundImage } from '../hooks/useBackgroundImage';
import { Category } from '../types'; import { CategoryName } from '../types';
export function HomePage() { export function HomePage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const category = searchParams.get('category') as Category | null; const categoryName = searchParams.get('category') as CategoryName;
const backgroundImage = useBackgroundImage(category); const backgroundImage = useBackgroundImage(categoryName);
console.log(categoryName)
const getHeroTitle = () => { const getHeroTitle = () => {
if (category) { if (categoryName) {
return { return {
main: getCategoryTitle(category), main: getCategoryTitle(categoryName),
sub: getCategoryDescription(category), sub: getCategoryDescription(categoryName),
description: getCategoryText(category) description: getCategoryText(categoryName)
}; };
} }
return { return {
@ -24,7 +26,7 @@ export function HomePage() {
}; };
}; };
const getCategoryTitle = (category: Category): string => { const getCategoryTitle = (name: CategoryName): string => {
const title = { const title = {
Film: 'Кино', Film: 'Кино',
Theater: 'Театр', Theater: 'Театр',
@ -35,10 +37,10 @@ export function HomePage() {
Anniversaries: 'Юбилеи', Anniversaries: 'Юбилеи',
Memory: 'Память' Memory: 'Память'
}; };
return title[category]; return title[name];
}; };
const getCategoryDescription = (category: Category): string => { const getCategoryDescription = (name: CategoryName): string => {
const descriptions = { const descriptions = {
Film: 'Свет, камера, действие! Магия кино', Film: 'Свет, камера, действие! Магия кино',
Theater: 'Гармония актёра и зрителя', Theater: 'Гармония актёра и зрителя',
@ -49,10 +51,10 @@ export function HomePage() {
Anniversaries: 'Вехи истории и великие даты', Anniversaries: 'Вехи истории и великие даты',
Memory: 'Память о великом и наследие' Memory: 'Память о великом и наследие'
}; };
return descriptions[category]; return descriptions[name];
}; };
const getCategoryText = (category: Category): string => { const getCategoryText = (name: CategoryName): string => {
const subtexts = { const subtexts = {
Film: 'Узнайте о кино-премьерах, фестивалях и знаковых фильмах Москвы и Петербурга. Мир кино открывается для вас.', Film: 'Узнайте о кино-премьерах, фестивалях и знаковых фильмах Москвы и Петербурга. Мир кино открывается для вас.',
Theater: 'Откройте для себя театральные премьеры и закулисье лучших сцен Москвы и Петербурга.', Theater: 'Откройте для себя театральные премьеры и закулисье лучших сцен Москвы и Петербурга.',
@ -63,7 +65,7 @@ export function HomePage() {
Anniversaries: 'Погрузитесь в исторические события и юбилеи, которые оставляют след в культуре двух столиц.', Anniversaries: 'Погрузитесь в исторические события и юбилеи, которые оставляют след в культуре двух столиц.',
Memory: 'Сохраняем культурные традиции и память о великих событиях и людях.' Memory: 'Сохраняем культурные традиции и память о великих событиях и людях.'
}; };
return subtexts[category]; return subtexts[name];
}; };
const { main, sub, description } = getHeroTitle(); const { main, sub, description } = getHeroTitle();

View File

@ -32,23 +32,6 @@ export function LoginPage() {
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900"> <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Войдите в свой эккаунт Войдите в свой эккаунт
</h2> </h2>
{/* Temporary credentials notice */}
<div className="mt-4 p-4 bg-blue-50 rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<AlertCircle className="h-5 w-5 text-blue-400" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800">
Development Credentials
</h3>
<div className="mt-2 text-sm text-blue-700">
<p>Email: admin@culturescope.com</p>
<p>Password: admin123</p>
</div>
</div>
</div>
</div>
</div> </div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}> <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && ( {error && (

View File

@ -1,11 +1,19 @@
import React, { useState, useEffect } from 'react';
import { Header } from '../components/Header'; import { Header } from '../components/Header';
import { AuthGuard } from '../components/AuthGuard'; import { AuthGuard } from '../components/AuthGuard';
import { User } from '../types/auth';
import { Category, City } from '../types'; import { Category, City } from '../types';
import { useUserManagement } from '../hooks/useUserManagement'; import { useUserManagement } from '../hooks/useUserManagement';
const categories: Category[] = ['Film', 'Theater', 'Music', 'Sports', 'Art', 'Legends', 'Anniversaries', 'Memory']; const categories: Category[] = [
{id : 1, name : 'Film'},
{id : 2, name : 'Theater'},
{id : 3, name : 'Music'},
{id : 4, name : 'Sports'},
{id : 5, name : 'Art'},
{id : 6, name : 'Legends'},
{id : 7, name : 'Anniversaries'},
{id : 8, name : 'Memory'}
];
const cities: City[] = ['New York', 'London']; const cities: City[] = ['New York', 'London'];
export function UserManagementPage() { export function UserManagementPage() {
@ -35,7 +43,7 @@ export function UserManagementPage() {
<div className="px-4 py-6 sm:px-0"> <div className="px-4 py-6 sm:px-0">
<div className="flex justify-between items-center mb-8"> <div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold text-gray-900"> <h1 className="text-2xl font-bold text-gray-900">
User Management Управление пользователями
</h1> </h1>
{error && ( {error && (
<div className="bg-red-50 text-red-700 px-4 py-2 rounded-md text-sm"> <div className="bg-red-50 text-red-700 px-4 py-2 rounded-md text-sm">
@ -47,7 +55,7 @@ export function UserManagementPage() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Users List */} {/* Users List */}
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Users</h2> <h2 className="text-lg font-medium text-gray-900 mb-4">Пользователи</h2>
<ul className="space-y-2"> <ul className="space-y-2">
{users.map((user) => ( {users.map((user) => (
<li key={user.id}> <li key={user.id}>
@ -71,19 +79,19 @@ export function UserManagementPage() {
{selectedUser && ( {selectedUser && (
<div className="md:col-span-2 bg-white rounded-lg shadow p-6"> <div className="md:col-span-2 bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4"> <h2 className="text-lg font-medium text-gray-900 mb-4">
Edit Permissions for {selectedUser.displayName} Редактирование прав пользователя "{selectedUser.displayName}"
</h2> </h2>
<div className="space-y-6"> <div className="space-y-6">
{/* Categories Permissions */} {/* Categories Permissions */}
<div> <div>
<h3 className="text-sm font-medium text-gray-700 mb-4"> <h3 className="text-sm font-medium text-gray-700 mb-4">
Category Permissions Права по категориям
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
{categories.map((category) => ( {categories.map((category) => (
<div key={category} className="border rounded-lg p-4"> <div key={category.id} className="border rounded-lg p-4">
<h4 className="font-medium mb-2">{category}</h4> <h4 className="font-medium mb-2">{category.name}</h4>
<div className="space-y-2"> <div className="space-y-2">
{(['create', 'edit', 'delete'] as const).map((action) => ( {(['create', 'edit', 'delete'] as const).map((action) => (
<label <label
@ -93,7 +101,7 @@ export function UserManagementPage() {
<input <input
type="checkbox" type="checkbox"
checked={ checked={
selectedUser.permissions.categories[category]?.[action] ?? false selectedUser.permissions.categories[category.name]?.[action] ?? false
} }
onChange={(e) => onChange={(e) =>
handlePermissionChange( handlePermissionChange(
@ -118,7 +126,7 @@ export function UserManagementPage() {
{/* Cities Access */} {/* Cities Access */}
<div> <div>
<h3 className="text-sm font-medium text-gray-700 mb-4"> <h3 className="text-sm font-medium text-gray-700 mb-4">
City Access Разрешения для города
</h3> </h3>
<div className="space-y-2"> <div className="space-y-2">
{cities.map((city) => ( {cities.map((city) => (

View File

@ -2,7 +2,15 @@ import axios from '../utils/api';
import { GalleryImage } from '../types'; import { GalleryImage } from '../types';
export const galleryService = { export const galleryService = {
createImage: async (articleId: string, imageData: Omit<GalleryImage, 'id'>) => { createImage: async (articleId: string, imageData: {
url: string;
caption: string;
alt: string;
width: number;
height: number;
size: number;
format: string
}) => {
const { data } = await axios.post(`/gallery/article/${articleId}`, imageData); const { data } = await axios.post(`/gallery/article/${articleId}`, imageData);
return data; return data;
}, },

View File

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

View File

@ -1,8 +1,8 @@
import { Category, City } from './index'; import { CategoryName, City } from './index';
export interface UserPermissions { export interface UserPermissions {
categories: { categories: {
[key in Category]?: { [key in CategoryName]?: {
create: boolean; create: boolean;
edit: boolean; edit: boolean;
delete: boolean; delete: boolean;

View File

@ -4,6 +4,7 @@ export interface Article {
excerpt: string; excerpt: string;
content: string; content: string;
category: Category; category: Category;
categoryId: number;
city: City; city: City;
author: Author; author: Author;
coverImage: string; coverImage: string;
@ -29,5 +30,25 @@ export interface Author {
bio: string; bio: string;
} }
export type Category = 'Film' | 'Theater' | 'Music' | 'Sports' | 'Art' | 'Legends' | 'Anniversaries' | 'Memory'; export interface Category {
id: number;
name: CategoryName;
}
export type CategoryName = 'Film' | 'Theater' | 'Music' | 'Sports' | 'Art' | 'Legends' | 'Anniversaries' | 'Memory';
export type City = 'New York' | 'London'; export type City = 'New York' | 'London';
export const CategoryNames: CategoryName[] = [
'Film', 'Theater', 'Music', 'Sports', 'Art', 'Legends', 'Anniversaries', 'Memory'
];
export const CategoryMap: Record<string, number> = {
Film: 1,
Theater: 2,
Music: 3,
Sports: 4,
Art: 5,
Legends: 6,
Anniversaries: 7,
Memory: 8,
};

28
src/utils/categories.ts Normal file
View File

@ -0,0 +1,28 @@
import { Category, CategoryName } from '../types';
export const categories: Category[] = [
{ id: 1, name: 'Film' },
{ id: 2, name: 'Theater' },
{ id: 3, name: 'Music' },
{ id: 4, name: 'Sports' },
{ id: 5, name: 'Art' },
{ id: 6, name: 'Legends' },
{ id: 7, name: 'Anniversaries' },
{ id: 8, name: 'Memory' }
];
export function getCategoryName(id: number): CategoryName {
const category = categories.find(c => c.id === id);
if (!category) {
throw new Error(`Invalid category ID: ${id}`);
}
return category.name;
}
export function getCategoryId(name: CategoryName): number {
const category = categories.find(c => c.name === name);
if (!category) {
throw new Error(`Invalid category name: ${name}`);
}
return category.id;
}

View File

@ -7,7 +7,7 @@ export const checkPermission = (
action: 'create' | 'edit' | 'delete' action: 'create' | 'edit' | 'delete'
): boolean => { ): boolean => {
if (user.permissions.isAdmin) return true; if (user.permissions.isAdmin) return true;
return !!user.permissions.categories[category]?.[action]; return !!user.permissions.categories[category.name]?.[action];
}; };
export const checkCityAccess = (user: User, city: City): boolean => { export const checkCityAccess = (user: User, city: City): boolean => {

View File

@ -5,10 +5,18 @@
{ "path": "./tsconfig.node.json" } { "path": "./tsconfig.node.json" }
], ],
"compilerOptions": { "compilerOptions": {
"target": "esnext", // Compile to a modern ECMAScript version that supports ES modules "target": "ESNext", // Compile to a modern ECMAScript version that supports ES modules
"module": "esnext", // Use ES module syntax for module code generation "module": "ESNext", // Use ES module syntax for module code generation
"strict": true,
"strictNullChecks": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node", // Use Node.js style module resolution "moduleResolution": "node", // Use Node.js style module resolution
"esModuleInterop": true, // Enables default imports from modules with no default export "esModuleInterop": true, // Enables default imports from modules with no default export
"outDir": "./dist" // Output directory for compiled files "outDir": "./dist" // Output directory for compiled files
} },
"ts-node": {
"esm": true
},
"include": ["src/**/*.ts"]
} }