Работает загрузка изображения статьи в S3 и сохранение новой статьи.
This commit is contained in:
parent
2a3f179f54
commit
5cc46da09d
548
package-lock.json
generated
548
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
45
prisma/migrations/20250108142452_category_id/migration.sql
Normal file
45
prisma/migrations/20250108142452_category_id/migration.sql
Normal 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;
|
@ -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
16
prisma/seed.js
Normal 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
36
prisma/seed.ts
Normal 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();
|
||||||
|
});
|
23
public/images/odnoklassniki-blk.svg
Normal file
23
public/images/odnoklassniki-blk.svg
Normal 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 |
24
public/images/odnoklassniki.svg
Normal file
24
public/images/odnoklassniki.svg
Normal 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
37
public/images/ok-11.svg
Normal 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
33
public/images/ok.svg
Normal 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 |
@ -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 };
|
|
@ -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}`);
|
|
||||||
});
|
|
@ -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);
|
|
||||||
});
|
|
@ -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' });
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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';
|
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
|
|
||||||
});
|
|
||||||
}
|
|
@ -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);
|
|
||||||
};
|
|
@ -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();
|
|
||||||
};
|
|
@ -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' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
@ -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' });
|
|
||||||
}
|
|
||||||
}
|
|
@ -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' });
|
|
||||||
}
|
|
||||||
}
|
|
@ -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' });
|
|
||||||
}
|
|
||||||
}
|
|
@ -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' });
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
@ -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' });
|
|
||||||
}
|
|
||||||
}
|
|
@ -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' });
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
@ -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' });
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
@ -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)
|
|
||||||
})
|
|
||||||
});
|
|
@ -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' });
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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' });
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
@ -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);
|
|
||||||
};
|
|
@ -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" />
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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 />
|
||||||
|
@ -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
|
||||||
|
116
src/components/ImageUpload/CoverImageUpload.tsx
Normal file
116
src/components/ImageUpload/CoverImageUpload.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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';
|
||||||
|
@ -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',
|
||||||
|
@ -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
|
||||||
|
@ -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;
|
||||||
}
|
}
|
@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -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}
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
@ -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();
|
||||||
|
@ -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 && (
|
||||||
|
@ -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) => (
|
||||||
|
@ -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;
|
||||||
},
|
},
|
||||||
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
||||||
|
@ -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
28
src/utils/categories.ts
Normal 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;
|
||||||
|
}
|
@ -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 => {
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user