first commit
This commit is contained in:
commit
036d28cff9
3
.bolt/config.json
Normal file
3
.bolt/config.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"template": "bolt-vite-react-ts"
|
||||
}
|
8
.bolt/prompt
Normal file
8
.bolt/prompt
Normal file
@ -0,0 +1,8 @@
|
||||
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
|
||||
|
||||
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
|
||||
|
||||
Use icons from lucide-react for logos.
|
||||
|
||||
Use stock photos from unsplash where appropriate, only valid URLs you know exist. Do not download the images, only link to them in image tags.
|
||||
|
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
5809
package-lock.json
generated
Normal file
5809
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
package.json
Normal file
47
package.json
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "vite-react-typescript-starter",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"server": "node server/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.10.2",
|
||||
"@tiptap/pm": "^2.2.4",
|
||||
"@tiptap/react": "^2.2.4",
|
||||
"@tiptap/starter-kit": "^2.2.4",
|
||||
"axios": "^1.6.7",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.18.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"zustand": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.1",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.11",
|
||||
"globals": "^15.9.0",
|
||||
"postcss": "^8.4.35",
|
||||
"prisma": "^5.10.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.3.0",
|
||||
"vite": "^5.4.2"
|
||||
}
|
||||
}
|
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
51
prisma/migrations/20241202174256_init/migration.sql
Normal file
51
prisma/migrations/20241202174256_init/migration.sql
Normal file
@ -0,0 +1,51 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"displayName" TEXT NOT NULL,
|
||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"permissions" JSONB NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Article" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"excerpt" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"city" TEXT NOT NULL,
|
||||
"coverImage" TEXT NOT NULL,
|
||||
"readTime" INTEGER NOT NULL,
|
||||
"likes" INTEGER NOT NULL DEFAULT 0,
|
||||
"dislikes" INTEGER NOT NULL DEFAULT 0,
|
||||
"publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"authorId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Article_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserReaction" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"articleId" TEXT NOT NULL,
|
||||
"reaction" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "UserReaction_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "UserReaction_userId_articleId_key" ON "UserReaction"("userId", "articleId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Article" ADD CONSTRAINT "Article_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
46
prisma/schema.prisma
Normal file
46
prisma/schema.prisma
Normal file
@ -0,0 +1,46 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
password String
|
||||
displayName String
|
||||
isAdmin Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
permissions Json
|
||||
articles Article[]
|
||||
}
|
||||
|
||||
model Article {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
excerpt String
|
||||
content String
|
||||
category String
|
||||
city String
|
||||
coverImage String
|
||||
readTime Int
|
||||
likes Int @default(0)
|
||||
dislikes Int @default(0)
|
||||
publishedAt DateTime @default(now())
|
||||
author User @relation(fields: [authorId], references: [id])
|
||||
authorId String
|
||||
}
|
||||
|
||||
model UserReaction {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
articleId String
|
||||
reaction String // 'like' or 'dislike'
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([userId, articleId])
|
||||
}
|
26
server/index.js
Normal file
26
server/index.js
Normal file
@ -0,0 +1,26 @@
|
||||
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.js';
|
||||
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}`);
|
||||
});
|
24
server/index.ts
Normal file
24
server/index.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import articleRoutes from './routes/articles.js';
|
||||
import userRoutes from './routes/users.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
|
||||
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}`);
|
||||
});
|
28
server/middleware/auth.js
Normal file
28
server/middleware/auth.js
Normal file
@ -0,0 +1,28 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export const auth = async (req, res, next) => {
|
||||
try {
|
||||
const token = req.header('Authorization')?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.id }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(401).json({ error: 'Please authenticate' });
|
||||
}
|
||||
};
|
40
server/middleware/auth.ts
Normal file
40
server/middleware/auth.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { User } from '../../src/types/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface AuthRequest extends Request {
|
||||
user?: User;
|
||||
}
|
||||
|
||||
export const auth = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const token = req.header('Authorization')?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'fallback-secret') as { id: string };
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
permissions: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
req.user = user as User;
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(401).json({ error: 'Please authenticate' });
|
||||
}
|
||||
};
|
91
server/routes/articles.js
Normal file
91
server/routes/articles.js
Normal file
@ -0,0 +1,91 @@
|
||||
import express from 'express';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { auth } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Search articles
|
||||
router.get('/search', async (req, res) => {
|
||||
try {
|
||||
const { q, page = 1, limit = 9 } = req.query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where = {
|
||||
OR: [
|
||||
{ title: { contains: q, mode: 'insensitive' } },
|
||||
{ excerpt: { contains: q, mode: 'insensitive' } },
|
||||
{ content: { contains: q, mode: 'insensitive' } },
|
||||
]
|
||||
};
|
||||
|
||||
const [articles, total] = await Promise.all([
|
||||
prisma.article.findMany({
|
||||
where,
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
email: true
|
||||
}
|
||||
}
|
||||
},
|
||||
skip,
|
||||
take: parseInt(limit),
|
||||
orderBy: { publishedAt: 'desc' }
|
||||
}),
|
||||
prisma.article.count({ where })
|
||||
]);
|
||||
|
||||
res.json({
|
||||
articles,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
currentPage: parseInt(page)
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get articles with pagination and filters
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { page = 1, category, city } = req.query;
|
||||
const perPage = 6;
|
||||
|
||||
const where = {
|
||||
...(category && { category }),
|
||||
...(city && { city })
|
||||
};
|
||||
|
||||
const [articles, total] = await Promise.all([
|
||||
prisma.article.findMany({
|
||||
where,
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
email: true
|
||||
}
|
||||
}
|
||||
},
|
||||
skip: (page - 1) * perPage,
|
||||
take: perPage,
|
||||
orderBy: { publishedAt: 'desc' }
|
||||
}),
|
||||
prisma.article.count({ where })
|
||||
]);
|
||||
|
||||
res.json({
|
||||
articles,
|
||||
totalPages: Math.ceil(total / perPage),
|
||||
currentPage: parseInt(page)
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Rest of the routes remain the same...
|
38
server/routes/auth.js
Normal file
38
server/routes/auth.js
Normal file
@ -0,0 +1,38 @@
|
||||
import express from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Login
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email }
|
||||
});
|
||||
|
||||
if (!user || !await bcrypt.compare(password, user.password)) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
permissions: user.permissions
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
27
server/routes/auth.ts
Normal file
27
server/routes/auth.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import express from 'express';
|
||||
import { authService } from '../services/authService.js';
|
||||
import { auth } from '../middleware/auth.js';
|
||||
|
||||
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 (error) {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user route
|
||||
router.get('/me', auth, async (req, res) => {
|
||||
try {
|
||||
res.json(req.user);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
93
server/routes/users.js
Normal file
93
server/routes/users.js
Normal file
@ -0,0 +1,93 @@
|
||||
import express from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { auth } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Get all users (admin only)
|
||||
router.get('/', auth, async (req, res) => {
|
||||
try {
|
||||
if (!req.user.isAdmin) {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
permissions: true,
|
||||
isAdmin: true
|
||||
}
|
||||
});
|
||||
|
||||
res.json(users);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update user permissions (admin only)
|
||||
router.put('/:id/permissions', auth, async (req, res) => {
|
||||
try {
|
||||
if (!req.user.isAdmin) {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { permissions } = req.body;
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id },
|
||||
data: { permissions },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
permissions: true,
|
||||
isAdmin: true
|
||||
}
|
||||
});
|
||||
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create new user (admin only)
|
||||
router.post('/', auth, async (req, res) => {
|
||||
try {
|
||||
if (!req.user.isAdmin) {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
|
||||
const { email, password, displayName, permissions } = req.body;
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: hashedPassword,
|
||||
displayName,
|
||||
permissions
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
permissions: true,
|
||||
isAdmin: true
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json(user);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
33
server/routes/users.ts
Normal file
33
server/routes/users.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import express from 'express';
|
||||
import { userService } from '../services/userService.js';
|
||||
import { auth } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', auth, async (req, res) => {
|
||||
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, res) => {
|
||||
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;
|
61
server/services/authService.ts
Normal file
61
server/services/authService.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { User } from '../../src/types/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export const authService = {
|
||||
login: async (email: string, password: string) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
password: true,
|
||||
displayName: true,
|
||||
permissions: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user || !await bcrypt.compare(password, user.password)) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{ id: user.id },
|
||||
process.env.JWT_SECRET || 'fallback-secret',
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
const { password: _, ...userWithoutPassword } = user;
|
||||
return {
|
||||
user: userWithoutPassword as User,
|
||||
token
|
||||
};
|
||||
},
|
||||
|
||||
createUser: async (userData: {
|
||||
email: string;
|
||||
password: string;
|
||||
displayName: string;
|
||||
permissions: any;
|
||||
}) => {
|
||||
const hashedPassword = await bcrypt.hash(userData.password, 10);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
...userData,
|
||||
password: hashedPassword
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
permissions: true
|
||||
}
|
||||
});
|
||||
|
||||
return user as User;
|
||||
}
|
||||
};
|
44
server/services/userService.ts
Normal file
44
server/services/userService.ts
Normal file
@ -0,0 +1,44 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
};
|
72
src/App.tsx
Normal file
72
src/App.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { ArticlePage } from './pages/ArticlePage';
|
||||
import { AdminPage } from './pages/AdminPage';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { UserManagementPage } from './pages/UserManagementPage';
|
||||
import { SearchPage } from './pages/SearchPage';
|
||||
import { Footer } from './components/Footer';
|
||||
import { AuthGuard } from './components/AuthGuard';
|
||||
|
||||
function App() {
|
||||
const { setUser, setLoading } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
setLoading(true);
|
||||
axios.get('http://localhost:5000/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
.then(response => {
|
||||
setUser(response.data);
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem('token');
|
||||
setUser(null);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [setUser, setLoading]);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||
<div className="flex-1">
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/article/:id" element={<ArticlePage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<AuthGuard>
|
||||
<AdminPage />
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={
|
||||
<AuthGuard>
|
||||
<UserManagementPage />
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
69
src/components/ArticleCard.tsx
Normal file
69
src/components/ArticleCard.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { Clock, ThumbsUp, MapPin } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Article } from '../types';
|
||||
|
||||
interface ArticleCardProps {
|
||||
article: Article;
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
export function ArticleCard({ article, featured = false }: ArticleCardProps) {
|
||||
return (
|
||||
<article className={`overflow-hidden rounded-lg shadow-lg transition-transform hover:scale-[1.02] ${
|
||||
featured ? 'col-span-2 row-span-2' : ''
|
||||
}`}>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={article.coverImage}
|
||||
alt={article.title}
|
||||
className={`w-full object-cover ${featured ? 'h-96' : 'h-64'}`}
|
||||
/>
|
||||
<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">
|
||||
{article.category}
|
||||
</span>
|
||||
<span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium flex items-center">
|
||||
<MapPin size={14} className="mr-1" />
|
||||
{article.city}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<img
|
||||
src={article.author.avatar}
|
||||
alt={article.author.name}
|
||||
className="w-10 h-10 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{article.author.name}</p>
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Clock size={14} className="mr-1" />
|
||||
{article.readTime} min read
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className={`font-bold text-gray-900 mb-2 ${featured ? 'text-2xl' : 'text-xl'}`}>
|
||||
{article.title}
|
||||
</h2>
|
||||
<p className="text-gray-600 line-clamp-2">{article.excerpt}</p>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<Link
|
||||
to={`/article/${article.id}`}
|
||||
className="text-blue-600 font-medium hover:text-blue-800"
|
||||
>
|
||||
Read More →
|
||||
</Link>
|
||||
<div className="flex items-center text-gray-500">
|
||||
<ThumbsUp size={16} className="mr-1" />
|
||||
<span>{article.likes}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
50
src/components/AuthGuard.tsx
Normal file
50
src/components/AuthGuard.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { Category } from '../types';
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: React.ReactNode;
|
||||
requiredPermissions?: {
|
||||
category?: Category;
|
||||
action: 'create' | 'edit' | 'delete';
|
||||
};
|
||||
}
|
||||
|
||||
export function AuthGuard({ children, requiredPermissions }: AuthGuardProps) {
|
||||
const { user, loading } = useAuthStore();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
if (requiredPermissions) {
|
||||
const { category, action } = requiredPermissions;
|
||||
|
||||
if (!user.permissions.isAdmin) {
|
||||
if (category && !user.permissions.categories[category]?.[action]) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
|
||||
<p className="text-gray-600">
|
||||
You don't have permission to {action} articles in the {category} category.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
90
src/components/FeaturedSection.tsx
Normal file
90
src/components/FeaturedSection.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { ArticleCard } from './ArticleCard';
|
||||
import { Pagination } from './Pagination';
|
||||
import { articles } from '../data/mock';
|
||||
|
||||
const ARTICLES_PER_PAGE = 6;
|
||||
|
||||
export function FeaturedSection() {
|
||||
const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const category = searchParams.get('category');
|
||||
const city = searchParams.get('city');
|
||||
const currentPage = parseInt(searchParams.get('page') || '1', 10);
|
||||
|
||||
const filteredArticles = useMemo(() => {
|
||||
return articles.filter(article => {
|
||||
if (category && city) {
|
||||
return article.category === category && article.city === city;
|
||||
}
|
||||
if (category) {
|
||||
return article.category === category;
|
||||
}
|
||||
if (city) {
|
||||
return article.city === city;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [category, city]);
|
||||
|
||||
const totalPages = Math.ceil(filteredArticles.length / ARTICLES_PER_PAGE);
|
||||
|
||||
const currentArticles = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * ARTICLES_PER_PAGE;
|
||||
return filteredArticles.slice(startIndex, startIndex + ARTICLES_PER_PAGE);
|
||||
}, [filteredArticles, currentPage]);
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
searchParams.set('page', page.toString());
|
||||
setSearchParams(searchParams);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
if (filteredArticles.length === 0) {
|
||||
return (
|
||||
<section className="py-12 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-xl font-medium text-gray-900 mb-2">
|
||||
No articles found
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
Please try a different category or city
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-12 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900">
|
||||
{city ? `${city} ` : ''}
|
||||
{category ? `${category} Stories` : 'Featured Stories'}
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Showing {Math.min(currentPage * ARTICLES_PER_PAGE, filteredArticles.length)} of {filteredArticles.length} articles
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{currentArticles.map((article, index) => (
|
||||
<ArticleCard
|
||||
key={article.id}
|
||||
article={article}
|
||||
featured={currentPage === 1 && index === 0 && !category && !city}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
141
src/components/Footer.tsx
Normal file
141
src/components/Footer.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Mail, Phone, Instagram, Twitter, Facebook, ExternalLink } from 'lucide-react';
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="bg-gray-900 text-gray-300">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
{/* About Section */}
|
||||
<div className="col-span-2">
|
||||
<h2 className="text-xl font-bold text-white mb-4">About CultureScope</h2>
|
||||
<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,
|
||||
and cultural stories from around the globe.
|
||||
</p>
|
||||
<div className="flex space-x-4">
|
||||
<a href="https://twitter.com" className="hover:text-white transition-colors">
|
||||
<Twitter size={20} />
|
||||
</a>
|
||||
<a href="https://facebook.com" className="hover:text-white transition-colors">
|
||||
<Facebook size={20} />
|
||||
</a>
|
||||
<a href="https://instagram.com" className="hover:text-white transition-colors">
|
||||
<Instagram size={20} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Quick Links</h3>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<Link to="/?category=Film" className="hover:text-white transition-colors">
|
||||
Film
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/?category=Theater" className="hover:text-white transition-colors">
|
||||
Theater
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/?category=Music" className="hover:text-white transition-colors">
|
||||
Music
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/?category=Sports" className="hover:text-white transition-colors">
|
||||
Sports
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/?category=Art" className="hover:text-white transition-colors">
|
||||
Art
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/?category=Legends" className="hover:text-white transition-colors">
|
||||
Legends
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/?category=Anniversaries" className="hover:text-white transition-colors">
|
||||
Anniversaries
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/?category=Memory" className="hover:text-white transition-colors">
|
||||
Memory
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Contact Info */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Contact Us</h3>
|
||||
<ul className="space-y-4">
|
||||
<li className="flex items-center">
|
||||
<Mail size={18} className="mr-2" />
|
||||
<a href="mailto:contact@culturescope.com" className="hover:text-white transition-colors">
|
||||
contact@culturescope.com
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<Phone size={18} className="mr-2" />
|
||||
<a href="tel:+1234567890" className="hover:text-white transition-colors">
|
||||
(123) 456-7890
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold text-white mt-6 mb-4">Our Partners</h3>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-center">
|
||||
<ExternalLink size={16} className="mr-2" />
|
||||
<a href="https://metropolitan.museum" className="hover:text-white transition-colors">
|
||||
Metropolitan Museum
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<ExternalLink size={16} className="mr-2" />
|
||||
<a href="https://royalalberthall.com" className="hover:text-white transition-colors">
|
||||
Royal Albert Hall
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<ExternalLink size={16} className="mr-2" />
|
||||
<a href="https://nationaltheatre.org.uk" className="hover:text-white transition-colors">
|
||||
National Theatre
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-800 mt-12 pt-8">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||
<p className="text-sm text-gray-400">
|
||||
© {new Date().getFullYear()} CultureScope. All rights reserved.
|
||||
</p>
|
||||
<div className="flex space-x-6 mt-4 md:mt-0">
|
||||
<Link to="/privacy" className="text-sm text-gray-400 hover:text-white transition-colors">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link to="/terms" className="text-sm text-gray-400 hover:text-white transition-colors">
|
||||
Terms of Service
|
||||
</Link>
|
||||
<Link to="/sitemap" className="text-sm text-gray-400 hover:text-white transition-colors">
|
||||
Sitemap
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
220
src/components/GalleryManager.tsx
Normal file
220
src/components/GalleryManager.tsx
Normal file
@ -0,0 +1,220 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Plus, Move, Pencil, Trash2 } from 'lucide-react';
|
||||
import { GalleryImage } from '../types';
|
||||
|
||||
interface GalleryManagerProps {
|
||||
images: GalleryImage[];
|
||||
onChange: (images: GalleryImage[]) => void;
|
||||
}
|
||||
|
||||
export function GalleryManager({ images, onChange }: GalleryManagerProps) {
|
||||
const [editingImage, setEditingImage] = useState<GalleryImage | null>(null);
|
||||
const [newImageUrl, setNewImageUrl] = useState('');
|
||||
const [newImageCaption, setNewImageCaption] = useState('');
|
||||
const [newImageAlt, setNewImageAlt] = useState('');
|
||||
|
||||
const handleAddImage = () => {
|
||||
if (!newImageUrl.trim()) return;
|
||||
|
||||
const newImage: GalleryImage = {
|
||||
id: Date.now().toString(),
|
||||
url: newImageUrl,
|
||||
caption: newImageCaption,
|
||||
alt: newImageAlt || newImageCaption
|
||||
};
|
||||
|
||||
onChange([...images, newImage]);
|
||||
setNewImageUrl('');
|
||||
setNewImageCaption('');
|
||||
setNewImageAlt('');
|
||||
};
|
||||
|
||||
const handleUpdateImage = (updatedImage: GalleryImage) => {
|
||||
onChange(images.map(img => img.id === updatedImage.id ? updatedImage : img));
|
||||
setEditingImage(null);
|
||||
};
|
||||
|
||||
const handleRemoveImage = (id: string) => {
|
||||
onChange(images.filter(img => img.id !== id));
|
||||
};
|
||||
|
||||
const handleReorder = (dragIndex: number, dropIndex: number) => {
|
||||
const reorderedImages = [...images];
|
||||
const [draggedImage] = reorderedImages.splice(dragIndex, 1);
|
||||
reorderedImages.splice(dropIndex, 0, draggedImage);
|
||||
onChange(reorderedImages);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Add New Image */}
|
||||
<div className="border rounded-lg p-4 space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Add New Image</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="imageUrl" className="block text-sm font-medium text-gray-700">
|
||||
Image URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="imageUrl"
|
||||
value={newImageUrl}
|
||||
onChange={(e) => setNewImageUrl(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
placeholder="https://example.com/image.jpg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="imageCaption" className="block text-sm font-medium text-gray-700">
|
||||
Caption
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="imageCaption"
|
||||
value={newImageCaption}
|
||||
onChange={(e) => setNewImageCaption(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="imageAlt" className="block text-sm font-medium text-gray-700">
|
||||
Alt Text
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="imageAlt"
|
||||
value={newImageAlt}
|
||||
onChange={(e) => setNewImageAlt(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddImage}
|
||||
disabled={!newImageUrl.trim()}
|
||||
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" />
|
||||
Add Image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gallery Preview */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Gallery Images</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{images.map((image, index) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative group border rounded-lg overflow-hidden"
|
||||
draggable
|
||||
onDragStart={(e) => e.dataTransfer.setData('text/plain', index.toString())}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const dragIndex = parseInt(e.dataTransfer.getData('text/plain'));
|
||||
handleReorder(dragIndex, index);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.alt}
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-50 transition-opacity duration-200">
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setEditingImage(image)}
|
||||
className="p-2 bg-white rounded-full text-gray-700 hover:text-blue-600"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveImage(image.id)}
|
||||
className="p-2 bg-white rounded-full text-gray-700 hover:text-red-600"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
<button className="p-2 bg-white rounded-full text-gray-700 cursor-move">
|
||||
<Move size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white p-2 text-sm">
|
||||
{image.caption}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Image Modal */}
|
||||
{editingImage && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg max-w-md w-full p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Edit Image</h3>
|
||||
<button
|
||||
onClick={() => setEditingImage(null)}
|
||||
className="text-gray-400 hover:text-gray-500"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Image URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={editingImage.url}
|
||||
onChange={(e) => setEditingImage({ ...editingImage, url: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Caption
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingImage.caption}
|
||||
onChange={(e) => setEditingImage({ ...editingImage, caption: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Alt Text
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingImage.alt}
|
||||
onChange={(e) => setEditingImage({ ...editingImage, alt: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => setEditingImage(null)}
|
||||
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
|
||||
onClick={() => handleUpdateImage(editingImage)}
|
||||
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
40
src/components/GalleryManager/EditImageModal.tsx
Normal file
40
src/components/GalleryManager/EditImageModal.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { GalleryImage } from '../../types';
|
||||
import { ImageForm } from './ImageForm';
|
||||
|
||||
interface EditImageModalProps {
|
||||
image: GalleryImage;
|
||||
onClose: () => void;
|
||||
onSave: (image: GalleryImage) => void;
|
||||
}
|
||||
|
||||
export function EditImageModal({ image, onClose, onSave }: EditImageModalProps) {
|
||||
const [editedImage, setEditedImage] = React.useState(image);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg max-w-md w-full p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Edit Image</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-500"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<ImageForm
|
||||
url={editedImage.url}
|
||||
caption={editedImage.caption}
|
||||
alt={editedImage.alt}
|
||||
onUrlChange={(url) => setEditedImage({ ...editedImage, url })}
|
||||
onCaptionChange={(caption) => setEditedImage({ ...editedImage, caption })}
|
||||
onAltChange={(alt) => setEditedImage({ ...editedImage, alt })}
|
||||
onSubmit={() => onSave(editedImage)}
|
||||
submitLabel="Save Changes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
61
src/components/GalleryManager/GalleryGrid.tsx
Normal file
61
src/components/GalleryManager/GalleryGrid.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { Move, Pencil, Trash2 } from 'lucide-react';
|
||||
import { GalleryImage } from '../../types';
|
||||
|
||||
interface GalleryGridProps {
|
||||
images: GalleryImage[];
|
||||
onEdit: (image: GalleryImage) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onReorder: (dragIndex: number, dropIndex: number) => void;
|
||||
}
|
||||
|
||||
export function GalleryGrid({ images, onEdit, onDelete, onReorder }: GalleryGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{images.map((image, index) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative group border rounded-lg overflow-hidden"
|
||||
draggable
|
||||
onDragStart={(e) => e.dataTransfer.setData('text/plain', index.toString())}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const dragIndex = parseInt(e.dataTransfer.getData('text/plain'));
|
||||
onReorder(dragIndex, index);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.alt}
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-50 transition-opacity duration-200">
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => onEdit(image)}
|
||||
className="p-2 bg-white rounded-full text-gray-700 hover:text-blue-600"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(image.id)}
|
||||
className="p-2 bg-white rounded-full text-gray-700 hover:text-red-600"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
<button className="p-2 bg-white rounded-full text-gray-700 cursor-move">
|
||||
<Move size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white p-2 text-sm">
|
||||
{image.caption}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
74
src/components/GalleryManager/ImageForm.tsx
Normal file
74
src/components/GalleryManager/ImageForm.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
interface ImageFormProps {
|
||||
url: string;
|
||||
caption: string;
|
||||
alt: string;
|
||||
onUrlChange: (url: string) => void;
|
||||
onCaptionChange: (caption: string) => void;
|
||||
onAltChange: (alt: string) => void;
|
||||
onSubmit: () => void;
|
||||
submitLabel: string;
|
||||
}
|
||||
|
||||
export function ImageForm({
|
||||
url,
|
||||
caption,
|
||||
alt,
|
||||
onUrlChange,
|
||||
onCaptionChange,
|
||||
onAltChange,
|
||||
onSubmit,
|
||||
submitLabel
|
||||
}: ImageFormProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="imageUrl" className="block text-sm font-medium text-gray-700">
|
||||
Image URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="imageUrl"
|
||||
value={url}
|
||||
onChange={(e) => onUrlChange(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
placeholder="https://example.com/image.jpg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="imageCaption" className="block text-sm font-medium text-gray-700">
|
||||
Caption
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="imageCaption"
|
||||
value={caption}
|
||||
onChange={(e) => onCaptionChange(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="imageAlt" className="block text-sm font-medium text-gray-700">
|
||||
Alt Text
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="imageAlt"
|
||||
value={alt}
|
||||
onChange={(e) => onAltChange(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={!url.trim()}
|
||||
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" />
|
||||
{submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
88
src/components/GalleryManager/index.tsx
Normal file
88
src/components/GalleryManager/index.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import React, { useState } from 'react';
|
||||
import { GalleryImage } from '../../types';
|
||||
import { GalleryGrid } from './GalleryGrid';
|
||||
import { ImageForm } from './ImageForm';
|
||||
import { EditImageModal } from './EditImageModal';
|
||||
|
||||
interface GalleryManagerProps {
|
||||
images: GalleryImage[];
|
||||
onChange: (images: GalleryImage[]) => void;
|
||||
}
|
||||
|
||||
export function GalleryManager({ images, onChange }: GalleryManagerProps) {
|
||||
const [editingImage, setEditingImage] = useState<GalleryImage | null>(null);
|
||||
const [newImageUrl, setNewImageUrl] = useState('');
|
||||
const [newImageCaption, setNewImageCaption] = useState('');
|
||||
const [newImageAlt, setNewImageAlt] = useState('');
|
||||
|
||||
const handleAddImage = () => {
|
||||
if (!newImageUrl.trim()) return;
|
||||
|
||||
const newImage: GalleryImage = {
|
||||
id: Date.now().toString(),
|
||||
url: newImageUrl,
|
||||
caption: newImageCaption,
|
||||
alt: newImageAlt || newImageCaption
|
||||
};
|
||||
|
||||
onChange([...images, newImage]);
|
||||
setNewImageUrl('');
|
||||
setNewImageCaption('');
|
||||
setNewImageAlt('');
|
||||
};
|
||||
|
||||
const handleUpdateImage = (updatedImage: GalleryImage) => {
|
||||
onChange(images.map(img => img.id === updatedImage.id ? updatedImage : img));
|
||||
setEditingImage(null);
|
||||
};
|
||||
|
||||
const handleRemoveImage = (id: string) => {
|
||||
onChange(images.filter(img => img.id !== id));
|
||||
};
|
||||
|
||||
const handleReorder = (dragIndex: number, dropIndex: number) => {
|
||||
const reorderedImages = [...images];
|
||||
const [draggedImage] = reorderedImages.splice(dragIndex, 1);
|
||||
reorderedImages.splice(dropIndex, 0, draggedImage);
|
||||
onChange(reorderedImages);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Add New Image */}
|
||||
<div className="border rounded-lg p-4 space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Add New Image</h3>
|
||||
<ImageForm
|
||||
url={newImageUrl}
|
||||
caption={newImageCaption}
|
||||
alt={newImageAlt}
|
||||
onUrlChange={setNewImageUrl}
|
||||
onCaptionChange={setNewImageCaption}
|
||||
onAltChange={setNewImageAlt}
|
||||
onSubmit={handleAddImage}
|
||||
submitLabel="Add Image"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gallery Preview */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Gallery Images</h3>
|
||||
<GalleryGrid
|
||||
images={images}
|
||||
onEdit={setEditingImage}
|
||||
onDelete={handleRemoveImage}
|
||||
onReorder={handleReorder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Edit Image Modal */}
|
||||
{editingImage && (
|
||||
<EditImageModal
|
||||
image={editingImage}
|
||||
onClose={() => setEditingImage(null)}
|
||||
onSave={handleUpdateImage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
189
src/components/Header.tsx
Normal file
189
src/components/Header.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Menu, Search, X, ChevronDown } from 'lucide-react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Category, City } from '../types';
|
||||
|
||||
const categories: Category[] = ['Film', 'Theater', 'Music', 'Sports', 'Art', 'Legends', 'Anniversaries', 'Memory'];
|
||||
const cities: City[] = ['New York', 'London'];
|
||||
|
||||
export function Header() {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const currentCategory = searchParams.get('category');
|
||||
const currentCity = searchParams.get('city');
|
||||
|
||||
const handleCityChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const city = event.target.value;
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
if (city) {
|
||||
params.set('city', city);
|
||||
} else {
|
||||
params.delete('city');
|
||||
}
|
||||
|
||||
navigate(`/?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim()) {
|
||||
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
className="p-2 rounded-md text-gray-500 lg:hidden hover:bg-gray-100"
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
>
|
||||
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
<Link to="/" className="ml-2 flex items-center space-x-2">
|
||||
<h1 className="text-2xl font-bold text-gray-900">CultureScope</h1>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="hidden lg:flex items-center space-x-8">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm font-medium text-gray-500"></span>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={currentCity || ''}
|
||||
onChange={handleCityChange}
|
||||
className="appearance-none bg-white pl-3 pr-8 py-2 text-sm font-medium rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent cursor-pointer"
|
||||
>
|
||||
<option value="">All Cities</option>
|
||||
{cities.map((city) => (
|
||||
<option key={city} value={city}>
|
||||
{city}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-400 pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
<div className="flex items-center space-x-4 overflow-x-auto">
|
||||
<span className="text-sm font-medium text-gray-500"></span>
|
||||
<Link
|
||||
to={currentCity ? `/?city=${currentCity}` : '/'}
|
||||
className={`px-3 py-2 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||
!currentCategory
|
||||
? 'text-blue-600 hover:text-blue-800'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
All Categories
|
||||
</Link>
|
||||
{categories.map((category) => (
|
||||
<Link
|
||||
key={category}
|
||||
to={`/?category=${category}${currentCity ? `&city=${currentCity}` : ''}`}
|
||||
className={`px-3 py-2 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||
currentCategory === category
|
||||
? 'text-blue-600 hover:text-blue-800'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<form onSubmit={handleSearch} className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyPress={handleSearchKeyPress}
|
||||
className="w-40 lg:w-60 pl-10 pr-4 py-2 text-sm border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute left-3 top-2.5 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<Search size={18} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
<div
|
||||
className={`lg:hidden ${
|
||||
isMobileMenuOpen ? 'block' : 'hidden'
|
||||
} border-b border-gray-200`}
|
||||
>
|
||||
<div className="px-2 pt-2 pb-3 space-y-1">
|
||||
<div className="px-3 py-2">
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">City</h3>
|
||||
<select
|
||||
value={currentCity || ''}
|
||||
onChange={handleCityChange}
|
||||
className="w-full bg-white px-3 py-2 text-base font-medium rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Cities</option>
|
||||
{cities.map((city) => (
|
||||
<option key={city} value={city}>
|
||||
{city}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">Categories</h3>
|
||||
<div className="space-y-1">
|
||||
<Link
|
||||
to={currentCity ? `/?city=${currentCity}` : '/'}
|
||||
className={`block px-3 py-2 rounded-md text-base font-medium ${
|
||||
!currentCategory
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
All Categories
|
||||
</Link>
|
||||
{categories.map((category) => (
|
||||
<Link
|
||||
key={category}
|
||||
to={`/?category=${category}${currentCity ? `&city=${currentCity}` : ''}`}
|
||||
className={`block px-3 py-2 rounded-md text-base font-medium ${
|
||||
currentCategory === category
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
46
src/components/Pagination.tsx
Normal file
46
src/components/Pagination.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
|
||||
const pages = Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
|
||||
return (
|
||||
<nav className="flex items-center justify-center space-x-1 mt-12">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
|
||||
{pages.map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onPageChange(page)}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium ${
|
||||
currentPage === page
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
96
src/components/PhotoGallery.tsx
Normal file
96
src/components/PhotoGallery.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { GalleryImage } from '../types';
|
||||
|
||||
interface PhotoGalleryProps {
|
||||
images: GalleryImage[];
|
||||
}
|
||||
|
||||
export function PhotoGallery({ images }: PhotoGalleryProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isLightboxOpen, setIsLightboxOpen] = useState(false);
|
||||
|
||||
const handlePrevious = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setCurrentIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1));
|
||||
};
|
||||
|
||||
const handleNext = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setCurrentIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1));
|
||||
};
|
||||
|
||||
const openLightbox = (index: number) => {
|
||||
setCurrentIndex(index);
|
||||
setIsLightboxOpen(true);
|
||||
document.body.style.overflow = 'hidden';
|
||||
};
|
||||
|
||||
const closeLightbox = () => {
|
||||
setIsLightboxOpen(false);
|
||||
document.body.style.overflow = 'auto';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="my-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{images.map((image, index) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative aspect-square cursor-pointer group overflow-hidden"
|
||||
onClick={() => openLightbox(index)}
|
||||
>
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.alt}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-opacity duration-300" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLightboxOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-90 z-50 flex items-center justify-center"
|
||||
onClick={closeLightbox}
|
||||
>
|
||||
<button
|
||||
className="absolute top-4 right-4 text-white hover:text-gray-300 transition-colors"
|
||||
onClick={closeLightbox}
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-white hover:text-gray-300 transition-colors"
|
||||
onClick={handlePrevious}
|
||||
>
|
||||
<ChevronLeft size={36} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white hover:text-gray-300 transition-colors"
|
||||
onClick={handleNext}
|
||||
>
|
||||
<ChevronRight size={36} />
|
||||
</button>
|
||||
|
||||
<div className="max-w-4xl max-h-[80vh] px-4" onClick={(e) => e.stopPropagation()}>
|
||||
<img
|
||||
src={images[currentIndex].url}
|
||||
alt={images[currentIndex].alt}
|
||||
className="max-w-full max-h-[70vh] object-contain mx-auto"
|
||||
/>
|
||||
<div className="text-center mt-4">
|
||||
<p className="text-white text-lg">{images[currentIndex].caption}</p>
|
||||
<p className="text-gray-400 text-sm mt-2">
|
||||
Image {currentIndex + 1} of {images.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
45
src/components/ReactionButtons.tsx
Normal file
45
src/components/ReactionButtons.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { ThumbsUp, ThumbsDown } from 'lucide-react';
|
||||
|
||||
interface ReactionButtonsProps {
|
||||
likes: number;
|
||||
dislikes: number;
|
||||
userReaction: 'like' | 'dislike' | null;
|
||||
onReact: (reaction: 'like' | 'dislike') => void;
|
||||
}
|
||||
|
||||
export function ReactionButtons({ likes, dislikes, userReaction, onReact }: ReactionButtonsProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => onReact('like')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-full transition-colors ${
|
||||
userReaction === 'like'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'hover:bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<ThumbsUp
|
||||
size={18}
|
||||
className={userReaction === 'like' ? 'fill-current' : ''}
|
||||
/>
|
||||
<span className="font-medium">{likes}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onReact('dislike')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-full transition-colors ${
|
||||
userReaction === 'dislike'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'hover:bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<ThumbsDown
|
||||
size={18}
|
||||
className={userReaction === 'dislike' ? 'fill-current' : ''}
|
||||
/>
|
||||
<span className="font-medium">{dislikes}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
168
src/data/mock.ts
Normal file
168
src/data/mock.ts
Normal file
@ -0,0 +1,168 @@
|
||||
import { Article, Author, City } from '../types';
|
||||
|
||||
export const authors: Author[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Elena Martinez',
|
||||
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',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'James Chen',
|
||||
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&q=80&w=150&h=150',
|
||||
bio: 'Music journalist and classical pianist',
|
||||
},
|
||||
];
|
||||
|
||||
export const articles: Article[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'The Renaissance of Independent Theater',
|
||||
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.',
|
||||
category: 'Theater',
|
||||
city: 'New York',
|
||||
author: authors[0],
|
||||
coverImage: 'https://images.unsplash.com/photo-1503095396549-807759245b35?auto=format&fit=crop&q=80&w=2070',
|
||||
gallery: [
|
||||
{
|
||||
id: '1',
|
||||
url: 'https://images.unsplash.com/photo-1507676184212-d03ab07a01bf?auto=format&fit=crop&q=80&w=2070',
|
||||
caption: 'Behind the scenes at the rehearsal',
|
||||
alt: 'Theater rehearsal in progress'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
url: 'https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8b?auto=format&fit=crop&q=80&w=2070',
|
||||
caption: 'Stage design and lighting setup',
|
||||
alt: 'Theater stage with dramatic lighting'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
url: 'https://images.unsplash.com/photo-1503095396549-807759245b35?auto=format&fit=crop&q=80&w=2070',
|
||||
caption: 'Opening night performance',
|
||||
alt: 'Actors performing on stage'
|
||||
}
|
||||
],
|
||||
publishedAt: '2024-03-15T10:00:00Z',
|
||||
readTime: 5,
|
||||
likes: 124,
|
||||
dislikes: 8,
|
||||
userReaction: null,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Evolution of Digital Art Museums',
|
||||
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.',
|
||||
category: 'Art',
|
||||
city: 'London',
|
||||
author: authors[1],
|
||||
coverImage: 'https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8b?auto=format&fit=crop&q=80&w=2070',
|
||||
gallery: [] ,
|
||||
publishedAt: '2024-03-14T09:00:00Z',
|
||||
readTime: 4,
|
||||
likes: 89,
|
||||
dislikes: 3,
|
||||
userReaction: null,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'The Future of Classical Music',
|
||||
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.',
|
||||
category: 'Music',
|
||||
city: 'London',
|
||||
author: authors[1],
|
||||
coverImage: 'https://images.unsplash.com/photo-1520523839897-bd0b52f945a0?auto=format&fit=crop&q=80&w=2070',
|
||||
gallery: [] ,
|
||||
publishedAt: '2024-03-13T10:00:00Z',
|
||||
readTime: 6,
|
||||
likes: 156,
|
||||
dislikes: 12,
|
||||
userReaction: null,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Modern Literature in the Digital Age',
|
||||
excerpt: 'How e-books and digital platforms are changing the way we consume and create literature.',
|
||||
content: 'The digital revolution has transformed the literary landscape, offering new ways for authors to reach readers and for stories to be told.',
|
||||
category: 'Literature',
|
||||
city: 'New York',
|
||||
author: authors[0],
|
||||
coverImage: 'https://images.unsplash.com/photo-1524995997946-a1c2e315a42f?auto=format&fit=crop&q=80&w=2070',
|
||||
gallery: [] ,
|
||||
publishedAt: '2024-03-12T09:00:00Z',
|
||||
readTime: 5,
|
||||
likes: 78,
|
||||
dislikes: 4,
|
||||
userReaction: null,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'The Rise of Immersive Art Installations',
|
||||
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.',
|
||||
category: 'Art',
|
||||
city: 'New York',
|
||||
author: authors[0],
|
||||
coverImage: 'https://images.unsplash.com/photo-1547826039-bfc35e0f1ea8?auto=format&fit=crop&q=80&w=2070',
|
||||
gallery: [] ,
|
||||
publishedAt: '2024-03-11T10:00:00Z',
|
||||
readTime: 7,
|
||||
likes: 201,
|
||||
dislikes: 15,
|
||||
userReaction: null,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Film Festivals in the Post-Pandemic Era',
|
||||
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.',
|
||||
category: 'Film',
|
||||
city: 'London',
|
||||
author: authors[1],
|
||||
coverImage: 'https://images.unsplash.com/photo-1478720568477-152d9b164e26?auto=format&fit=crop&q=80&w=2070',
|
||||
gallery: [] ,
|
||||
publishedAt: '2024-03-10T09:00:00Z',
|
||||
readTime: 4,
|
||||
likes: 167,
|
||||
dislikes: 9,
|
||||
userReaction: null,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
title: 'Street Art: From Vandalism to Validation',
|
||||
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.',
|
||||
category: 'Art',
|
||||
city: 'New York',
|
||||
author: authors[0],
|
||||
coverImage: 'https://images.unsplash.com/photo-1499781350541-7783f6c6a0c8?auto=format&fit=crop&q=80&w=2070',
|
||||
gallery: [] ,
|
||||
publishedAt: '2024-03-09T10:00:00Z',
|
||||
readTime: 6,
|
||||
likes: 145,
|
||||
dislikes: 7,
|
||||
userReaction: null,
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
title: 'The New Wave of Theater Technology',
|
||||
excerpt: 'How digital innovations are enhancing live theater performances.',
|
||||
content: 'Modern theater productions are incorporating cutting-edge technology to create immersive experiences.',
|
||||
category: 'Theater',
|
||||
city: 'London',
|
||||
author: authors[1],
|
||||
coverImage: 'https://images.unsplash.com/photo-1507676184212-d03ab07a01bf?auto=format&fit=crop&q=80&w=2070',
|
||||
gallery: [] ,
|
||||
publishedAt: '2024-03-08T09:00:00Z',
|
||||
readTime: 5,
|
||||
likes: 134,
|
||||
dislikes: 6,
|
||||
userReaction: null,
|
||||
},
|
||||
|
||||
// ... rest of the articles array remains the same
|
||||
];
|
48
src/data/mockUsers.ts
Normal file
48
src/data/mockUsers.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { User } from '../types/auth';
|
||||
import { Category, City } from '../types';
|
||||
|
||||
const categories: Category[] = ['Film', 'Theater', 'Music', 'Sports', 'Art', 'Legends', 'Anniversaries', 'Memory'];
|
||||
const cities: City[] = ['New York', 'London'];
|
||||
|
||||
export const mockUsers: User[] = [
|
||||
{
|
||||
id: '1',
|
||||
email: 'admin@culturescope.com',
|
||||
displayName: 'Admin User',
|
||||
permissions: {
|
||||
categories: categories.reduce((acc, category) => ({
|
||||
...acc,
|
||||
[category]: { create: true, edit: true, delete: true }
|
||||
}), {}),
|
||||
cities: cities,
|
||||
isAdmin: true
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
email: 'editor@culturescope.com',
|
||||
displayName: 'Content Editor',
|
||||
permissions: {
|
||||
categories: {
|
||||
'Art': { create: true, edit: true, delete: false },
|
||||
'Music': { create: true, edit: true, delete: false },
|
||||
'Film': { create: true, edit: true, delete: false }
|
||||
},
|
||||
cities: ['New York'],
|
||||
isAdmin: false
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
email: 'writer@culturescope.com',
|
||||
displayName: 'Staff Writer',
|
||||
permissions: {
|
||||
categories: {
|
||||
'Theater': { create: true, edit: false, delete: false },
|
||||
'Sports': { create: true, edit: false, delete: false }
|
||||
},
|
||||
cities: ['London'],
|
||||
isAdmin: false
|
||||
}
|
||||
}
|
||||
];
|
17
src/hooks/useBackgroundImage.ts
Normal file
17
src/hooks/useBackgroundImage.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Category } from '../types';
|
||||
|
||||
const backgroundImages = {
|
||||
Film: 'https://images.unsplash.com/photo-1478720568477-152d9b164e26?auto=format&fit=crop&q=80&w=2070',
|
||||
Theater: 'https://images.unsplash.com/photo-1507676184212-d03ab07a01bf?auto=format&fit=crop&q=80&w=2070',
|
||||
Music: 'https://images.unsplash.com/photo-1520523839897-bd0b52f945a0?auto=format&fit=crop&q=80&w=2070',
|
||||
Sports: 'https://images.unsplash.com/photo-1461896836934-ffe607ba8211?auto=format&fit=crop&q=80&w=2070',
|
||||
Art: 'https://images.unsplash.com/photo-1547826039-bfc35e0f1ea8?auto=format&fit=crop&q=80&w=2070',
|
||||
Legends: 'https://images.unsplash.com/photo-1608376630927-31bc8a3c9ee4?auto=format&fit=crop&q=80&w=2070',
|
||||
Anniversaries: 'https://images.unsplash.com/photo-1530533718754-001d2668365a?auto=format&fit=crop&q=80&w=2070',
|
||||
Memory: 'https://images.unsplash.com/photo-1494522358652-f30e61a60313?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) {
|
||||
return category ? backgroundImages[category] : backgroundImages.default;
|
||||
}
|
103
src/hooks/useUserManagement.ts
Normal file
103
src/hooks/useUserManagement.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { User } from '../types/auth';
|
||||
import { Category, City } from '../types';
|
||||
import { userService } from '../services/userService';
|
||||
|
||||
export function useUserManagement() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const fetchedUsers = await userService.getUsers();
|
||||
setUsers(fetchedUsers);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch users');
|
||||
console.error('Error fetching users:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const handlePermissionChange = async (
|
||||
category: Category,
|
||||
action: 'create' | 'edit' | 'delete',
|
||||
value: boolean
|
||||
) => {
|
||||
if (!selectedUser) return;
|
||||
|
||||
try {
|
||||
const updatedPermissions = {
|
||||
...selectedUser.permissions,
|
||||
categories: {
|
||||
...selectedUser.permissions.categories,
|
||||
[category]: {
|
||||
...selectedUser.permissions.categories[category],
|
||||
[action]: value,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const updatedUser = await userService.updateUserPermissions(
|
||||
selectedUser.id,
|
||||
updatedPermissions
|
||||
);
|
||||
|
||||
setSelectedUser(updatedUser);
|
||||
setUsers(users.map((user) =>
|
||||
user.id === selectedUser.id ? updatedUser : user
|
||||
));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to update permissions');
|
||||
console.error('Error updating permissions:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCityChange = async (city: City, checked: boolean) => {
|
||||
if (!selectedUser) return;
|
||||
|
||||
try {
|
||||
const updatedCities = checked
|
||||
? [...selectedUser.permissions.cities, city]
|
||||
: selectedUser.permissions.cities.filter((c) => c !== city);
|
||||
|
||||
const updatedPermissions = {
|
||||
...selectedUser.permissions,
|
||||
cities: updatedCities,
|
||||
};
|
||||
|
||||
const updatedUser = await userService.updateUserPermissions(
|
||||
selectedUser.id,
|
||||
updatedPermissions
|
||||
);
|
||||
|
||||
setSelectedUser(updatedUser);
|
||||
setUsers(users.map((user) =>
|
||||
user.id === selectedUser.id ? updatedUser : user
|
||||
));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to update city permissions');
|
||||
console.error('Error updating city permissions:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
users,
|
||||
selectedUser,
|
||||
loading,
|
||||
error,
|
||||
setSelectedUser,
|
||||
handlePermissionChange,
|
||||
handleCityChange
|
||||
};
|
||||
}
|
25
src/index.css
Normal file
25
src/index.css
Normal file
@ -0,0 +1,25 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply antialiased text-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.prose {
|
||||
@apply max-w-none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-shadow {
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
12
src/lib/prisma.ts
Normal file
12
src/lib/prisma.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Prevent multiple instances of Prisma Client in development
|
||||
declare global {
|
||||
var prisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
export const prisma = global.prisma || new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
global.prisma = prisma;
|
||||
}
|
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
479
src/pages/AdminPage.tsx
Normal file
479
src/pages/AdminPage.tsx
Normal file
@ -0,0 +1,479 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { Header } from '../components/Header';
|
||||
import { GalleryManager } from '../components/GalleryManager';
|
||||
import { Category, City, GalleryImage } from '../types';
|
||||
import { ImagePlus, Bold, Italic, List, ListOrdered, Quote, Pencil, Trash2, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { articles } from '../data/mock';
|
||||
|
||||
const categories: Category[] = ['Film', 'Theater', 'Music', 'Sports', 'Art', 'Legends', 'Anniversaries', 'Memory'];
|
||||
const cities: City[] = ['New York', 'London'];
|
||||
const ARTICLES_PER_PAGE = 5;
|
||||
|
||||
export function AdminPage() {
|
||||
const [title, setTitle] = useState('');
|
||||
const [excerpt, setExcerpt] = useState('');
|
||||
const [category, setCategory] = useState<Category>('Art');
|
||||
const [city, setCity] = useState<City>('New York');
|
||||
const [coverImage, setCoverImage] = useState('');
|
||||
const [readTime, setReadTime] = useState(5);
|
||||
const [gallery, setGallery] = useState<GalleryImage[]>([]);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState<string | null>(null);
|
||||
const [articlesList, setArticlesList] = useState(articles);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [filterCategory, setFilterCategory] = useState<Category | ''>('');
|
||||
const [filterCity, setFilterCity] = useState<City | ''>('');
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [StarterKit],
|
||||
content: '',
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-lg focus:outline-none min-h-[300px] max-w-none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const filteredArticles = useMemo(() => {
|
||||
return articlesList.filter(article => {
|
||||
if (filterCategory && filterCity) {
|
||||
return article.category === filterCategory && article.city === filterCity;
|
||||
}
|
||||
if (filterCategory) {
|
||||
return article.category === filterCategory;
|
||||
}
|
||||
if (filterCity) {
|
||||
return article.city === filterCity;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [articlesList, filterCategory, filterCity]);
|
||||
|
||||
const totalPages = Math.ceil(filteredArticles.length / ARTICLES_PER_PAGE);
|
||||
const paginatedArticles = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * ARTICLES_PER_PAGE;
|
||||
return filteredArticles.slice(startIndex, startIndex + ARTICLES_PER_PAGE);
|
||||
}, [filteredArticles, currentPage]);
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
const article = articlesList.find(a => a.id === id);
|
||||
if (article) {
|
||||
setTitle(article.title);
|
||||
setExcerpt(article.excerpt);
|
||||
setCategory(article.category);
|
||||
setCity(article.city);
|
||||
setCoverImage(article.coverImage);
|
||||
setReadTime(article.readTime);
|
||||
setGallery(article.gallery || []);
|
||||
editor?.commands.setContent(article.content);
|
||||
setEditingId(id);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setArticlesList(prev => prev.filter(article => article.id !== id));
|
||||
setShowDeleteModal(null);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const articleData = {
|
||||
title,
|
||||
excerpt,
|
||||
category,
|
||||
city,
|
||||
coverImage,
|
||||
readTime,
|
||||
gallery,
|
||||
content: editor?.getHTML() || '',
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
setArticlesList(prev =>
|
||||
prev.map(article =>
|
||||
article.id === editingId
|
||||
? { ...article, ...articleData }
|
||||
: article
|
||||
)
|
||||
);
|
||||
setEditingId(null);
|
||||
} else {
|
||||
// In a real app, this would send data to an API
|
||||
console.log('Creating new article:', articleData);
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setTitle('');
|
||||
setExcerpt('');
|
||||
setCategory('Art');
|
||||
setCity('New York');
|
||||
setCoverImage('');
|
||||
setReadTime(5);
|
||||
setGallery([]);
|
||||
editor?.commands.setContent('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header />
|
||||
<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">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">
|
||||
{editingId ? 'Edit Article' : 'Create New Article'}
|
||||
</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="excerpt" className="block text-sm font-medium text-gray-700">
|
||||
Excerpt
|
||||
</label>
|
||||
<textarea
|
||||
id="excerpt"
|
||||
rows={3}
|
||||
value={excerpt}
|
||||
onChange={(e) => setExcerpt(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as Category)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="city" className="block text-sm font-medium text-gray-700">
|
||||
City
|
||||
</label>
|
||||
<select
|
||||
id="city"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value as City)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
>
|
||||
{cities.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="readTime" className="block text-sm font-medium text-gray-700">
|
||||
Read Time (minutes)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="readTime"
|
||||
min="1"
|
||||
value={readTime}
|
||||
onChange={(e) => setReadTime(Number(e.target.value))}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="coverImage" className="block text-sm font-medium text-gray-700">
|
||||
Cover Image URL
|
||||
</label>
|
||||
<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>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Content
|
||||
</label>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="border-b bg-gray-50 px-4 py-2">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleBold().run()}
|
||||
className={`p-1 rounded hover:bg-gray-200 ${
|
||||
editor?.isActive('bold') ? 'bg-gray-200' : ''
|
||||
}`}
|
||||
>
|
||||
<Bold size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
className={`p-1 rounded hover:bg-gray-200 ${
|
||||
editor?.isActive('italic') ? 'bg-gray-200' : ''
|
||||
}`}
|
||||
>
|
||||
<Italic size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleBulletList().run()}
|
||||
className={`p-1 rounded hover:bg-gray-200 ${
|
||||
editor?.isActive('bulletList') ? 'bg-gray-200' : ''
|
||||
}`}
|
||||
>
|
||||
<List size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
|
||||
className={`p-1 rounded hover:bg-gray-200 ${
|
||||
editor?.isActive('orderedList') ? 'bg-gray-200' : ''
|
||||
}`}
|
||||
>
|
||||
<ListOrdered size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleBlockquote().run()}
|
||||
className={`p-1 rounded hover:bg-gray-200 ${
|
||||
editor?.isActive('blockquote') ? 'bg-gray-200' : ''
|
||||
}`}
|
||||
>
|
||||
<Quote size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-4">
|
||||
Photo Gallery
|
||||
</label>
|
||||
<GalleryManager
|
||||
images={gallery}
|
||||
onChange={setGallery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
{editingId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setTitle('');
|
||||
setExcerpt('');
|
||||
setCategory('Art');
|
||||
setCity('New York');
|
||||
setCoverImage('');
|
||||
setReadTime(5);
|
||||
setGallery([]);
|
||||
editor?.commands.setContent('');
|
||||
}}
|
||||
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
|
||||
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"
|
||||
>
|
||||
{editingId ? 'Update Article' : 'Publish Article'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Articles List */}
|
||||
<div className="bg-white rounded-lg shadow-sm">
|
||||
<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">
|
||||
<h2 className="text-lg font-medium text-gray-900">Published Articles</h2>
|
||||
<div className="flex gap-4">
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => {
|
||||
setFilterCategory(e.target.value as Category | '');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filterCity}
|
||||
onChange={(e) => {
|
||||
setFilterCity(e.target.value as City | '');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||||
>
|
||||
<option value="">All Cities</option>
|
||||
{cities.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{paginatedArticles.map((article) => (
|
||||
<li key={article.id} className="px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-gray-900 truncate">
|
||||
{article.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{article.category} · {article.city} · {article.readTime} min read
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 ml-4">
|
||||
<button
|
||||
onClick={() => handleEdit(article.id)}
|
||||
className="p-2 text-gray-400 hover:text-blue-600 rounded-full hover:bg-blue-50"
|
||||
>
|
||||
<Pencil size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteModal(article.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-600 rounded-full hover:bg-red-50"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-6 py-4 border-t border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
Showing {((currentPage - 1) * ARTICLES_PER_PAGE) + 1} to {Math.min(currentPage * ARTICLES_PER_PAGE, filteredArticles.length)} of {filteredArticles.length} articles
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => handlePageChange(page)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md ${
|
||||
currentPage === page
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteModal && (
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg max-w-md w-full p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Delete Article
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Are you sure you want to delete this article? This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end gap-4">
|
||||
<button
|
||||
onClick={() => setShowDeleteModal(null)}
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(showDeleteModal)}
|
||||
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
153
src/pages/ArticlePage.tsx
Normal file
153
src/pages/ArticlePage.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { ArrowLeft, Clock, Share2, Bookmark } from 'lucide-react';
|
||||
import { Header } from '../components/Header';
|
||||
import { ReactionButtons } from '../components/ReactionButtons';
|
||||
import { PhotoGallery } from '../components/PhotoGallery';
|
||||
import { articles } from '../data/mock';
|
||||
import { Article } from '../types';
|
||||
|
||||
export function ArticlePage() {
|
||||
const { id } = useParams();
|
||||
const [articleData, setArticleData] = useState<Article | undefined>(
|
||||
articles.find(a => a.id === id)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [id]);
|
||||
|
||||
if (!articleData) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Article not found</h2>
|
||||
<Link to="/" className="text-blue-600 hover:text-blue-800">
|
||||
Return to homepage
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleReaction = (reaction: 'like' | 'dislike') => {
|
||||
setArticleData(prev => {
|
||||
if (!prev) return prev;
|
||||
|
||||
const newArticle = { ...prev };
|
||||
|
||||
if (prev.userReaction === 'like') newArticle.likes--;
|
||||
if (prev.userReaction === 'dislike') newArticle.dislikes--;
|
||||
|
||||
if (prev.userReaction !== reaction) {
|
||||
if (reaction === 'like') newArticle.likes++;
|
||||
if (reaction === 'dislike') newArticle.dislikes++;
|
||||
newArticle.userReaction = reaction;
|
||||
} else {
|
||||
newArticle.userReaction = null;
|
||||
}
|
||||
|
||||
return newArticle;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Header />
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center text-gray-600 hover:text-gray-900 mb-8"
|
||||
>
|
||||
<ArrowLeft size={20} className="mr-2" />
|
||||
Back to articles
|
||||
</Link>
|
||||
|
||||
<article>
|
||||
{/* Article Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<img
|
||||
src={articleData.author.avatar}
|
||||
alt={articleData.author.name}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{articleData.author.name}</p>
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Clock size={14} className="mr-1" />
|
||||
{articleData.readTime} min read ·{' '}
|
||||
{new Date(articleData.publishedAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">{articleData.title}</h1>
|
||||
<p className="text-xl text-gray-600 mb-6">{articleData.excerpt}</p>
|
||||
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<span className="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm font-medium">
|
||||
{articleData.category}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<button className="p-2 text-gray-500 hover:text-gray-700 rounded-full hover:bg-gray-100">
|
||||
<Share2 size={20} />
|
||||
</button>
|
||||
<button className="p-2 text-gray-500 hover:text-gray-700 rounded-full hover:bg-gray-100">
|
||||
<Bookmark size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cover Image */}
|
||||
<img
|
||||
src={articleData.coverImage}
|
||||
alt={articleData.title}
|
||||
className="w-full h-[28rem] object-cover rounded-xl mb-8"
|
||||
/>
|
||||
|
||||
{/* Article Content */}
|
||||
<div className="prose prose-lg max-w-none mb-8">
|
||||
<div className="text-gray-800 leading-relaxed">
|
||||
{articleData.content}
|
||||
</div>
|
||||
|
||||
<p className="text-gray-800 leading-relaxed mt-6">
|
||||
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
</p>
|
||||
|
||||
<blockquote className="border-l-4 border-blue-500 pl-4 my-8 italic text-gray-700">
|
||||
"Art is not what you see, but what you make others see." - Edgar Degas
|
||||
</blockquote>
|
||||
|
||||
<p className="text-gray-800 leading-relaxed">
|
||||
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Photo Gallery */}
|
||||
{articleData.gallery && articleData.gallery.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Photo Gallery</h2>
|
||||
<PhotoGallery images={articleData.gallery} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Article Footer */}
|
||||
<div className="border-t pt-8">
|
||||
<ReactionButtons
|
||||
likes={articleData.likes}
|
||||
dislikes={articleData.dislikes}
|
||||
userReaction={articleData.userReaction}
|
||||
onReact={handleReaction}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
96
src/pages/HomePage.tsx
Normal file
96
src/pages/HomePage.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Header } from '../components/Header';
|
||||
import { FeaturedSection } from '../components/FeaturedSection';
|
||||
import { useBackgroundImage } from '../hooks/useBackgroundImage';
|
||||
import { Category } from '../types';
|
||||
|
||||
export function HomePage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const category = searchParams.get('category') as Category | null;
|
||||
const backgroundImage = useBackgroundImage(category);
|
||||
|
||||
const getHeroTitle = () => {
|
||||
if (category) {
|
||||
return {
|
||||
main: `Discover ${category}`,
|
||||
sub: getCategoryDescription(category)
|
||||
};
|
||||
}
|
||||
return {
|
||||
main: 'Discover the World of',
|
||||
sub: 'Arts & Culture'
|
||||
};
|
||||
};
|
||||
|
||||
const getCategoryDescription = (category: Category): string => {
|
||||
const descriptions = {
|
||||
Film: 'Cinema & Motion Pictures',
|
||||
Theater: 'Stage & Performance',
|
||||
Music: 'Rhythm & Harmony',
|
||||
Sports: 'Athletics & Competition',
|
||||
Art: 'Visual & Creative Expression',
|
||||
Legends: 'Stories & Heritage',
|
||||
Anniversaries: 'Celebrations & Milestones',
|
||||
Memory: 'History & Remembrance'
|
||||
};
|
||||
return descriptions[category];
|
||||
};
|
||||
|
||||
const { main, sub } = getHeroTitle();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main>
|
||||
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0">
|
||||
<div
|
||||
className="w-full h-full bg-cover bg-center transition-all duration-500 ease-in-out"
|
||||
style={{
|
||||
backgroundImage: `url("${backgroundImage}")`,
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-gray-900/90 to-gray-900/60 backdrop-blur-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="text-center max-w-4xl mx-auto py-24 px-4 sm:py-32 sm:px-6 lg:px-8">
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-white sm:text-5xl md:text-6xl">
|
||||
<span className="block mb-2">{main}</span>
|
||||
<span className="block text-blue-400">{sub}</span>
|
||||
</h1>
|
||||
<p className="mt-6 text-xl text-gray-200 max-w-2xl mx-auto">
|
||||
{category ?
|
||||
`Explore the latest ${category.toLowerCase()} stories, events, and cultural highlights from around the globe.` :
|
||||
'Explore the latest in art, music, theater, and cultural events from around the globe. Join us on a journey through the world\'s most inspiring creative expressions.'
|
||||
}
|
||||
</p>
|
||||
<div className="mt-8 flex justify-center space-x-4">
|
||||
<a
|
||||
href={category ? `/?category=${category}` : '/?category=Art'}
|
||||
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Explore Articles
|
||||
</a>
|
||||
<a
|
||||
href="#featured"
|
||||
className="inline-flex items-center px-6 py-3 border border-gray-300 text-base font-medium rounded-md text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
Latest Stories
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="featured">
|
||||
<FeaturedSection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
119
src/pages/LoginPage.tsx
Normal file
119
src/pages/LoginPage.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
// Temporary admin credentials - DEVELOPMENT ONLY
|
||||
const TEMP_ADMIN = {
|
||||
email: 'admin@culturescope.com',
|
||||
password: 'admin123'
|
||||
};
|
||||
|
||||
export function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const login = useAuthStore((state) => state.login);
|
||||
|
||||
const from = location.state?.from?.pathname || '/admin';
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await login(email, password); // Pass the admin user object
|
||||
navigate(from, { replace: true });
|
||||
} catch (err) {
|
||||
setError('An error occurred while logging in');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
{/* Temporary credentials notice */}
|
||||
<div className="mt-4 p-4 bg-blue-50 rounded-md">
|
||||
<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>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertCircle className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Email address"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
97
src/pages/SearchPage.tsx
Normal file
97
src/pages/SearchPage.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Header } from '../components/Header';
|
||||
import { ArticleCard } from '../components/ArticleCard';
|
||||
import { Pagination } from '../components/Pagination';
|
||||
import { Article } from '../types';
|
||||
import api from '../utils/api';
|
||||
|
||||
const ARTICLES_PER_PAGE = 9;
|
||||
|
||||
export function SearchPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const query = searchParams.get('q') || '';
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
|
||||
const [articles, setArticles] = useState<Article[]>([]);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchResults = async () => {
|
||||
if (!query) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get('/articles/search', {
|
||||
params: {
|
||||
q: query,
|
||||
page,
|
||||
limit: ARTICLES_PER_PAGE
|
||||
}
|
||||
});
|
||||
setArticles(response.data.articles);
|
||||
setTotalPages(response.data.totalPages);
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchResults();
|
||||
}, [query, page]);
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setSearchParams({ q: query, page: newPage.toString() });
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
{query ? `Search Results for "${query}"` : 'Search Articles'}
|
||||
</h1>
|
||||
{articles.length > 0 && (
|
||||
<p className="mt-2 text-gray-600">
|
||||
Found {articles.length} articles
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : articles.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{articles.map((article) => (
|
||||
<ArticleCard key={article.id} article={article} />
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : query ? (
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-xl font-medium text-gray-900 mb-2">
|
||||
No articles found
|
||||
</h2>
|
||||
<p className="text-gray-500">
|
||||
Try adjusting your search terms or browse our categories instead
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
151
src/pages/UserManagementPage.tsx
Normal file
151
src/pages/UserManagementPage.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Header } from '../components/Header';
|
||||
import { AuthGuard } from '../components/AuthGuard';
|
||||
import { User } from '../types/auth';
|
||||
import { Category, City } from '../types';
|
||||
import { useUserManagement } from '../hooks/useUserManagement';
|
||||
|
||||
const categories: Category[] = ['Film', 'Theater', 'Music', 'Sports', 'Art', 'Legends', 'Anniversaries', 'Memory'];
|
||||
const cities: City[] = ['New York', 'London'];
|
||||
|
||||
export function UserManagementPage() {
|
||||
const {
|
||||
users,
|
||||
selectedUser,
|
||||
loading,
|
||||
error,
|
||||
setSelectedUser,
|
||||
handlePermissionChange,
|
||||
handleCityChange
|
||||
} = useUserManagement();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header />
|
||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
User Management
|
||||
</h1>
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-700 px-4 py-2 rounded-md text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* Users List */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Users</h2>
|
||||
<ul className="space-y-2">
|
||||
{users.map((user) => (
|
||||
<li key={user.id}>
|
||||
<button
|
||||
onClick={() => setSelectedUser(user)}
|
||||
className={`w-full text-left px-4 py-2 rounded-md ${
|
||||
selectedUser?.id === user.id
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{user.displayName}</div>
|
||||
<div className="text-sm text-gray-500">{user.email}</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Permissions Editor */}
|
||||
{selectedUser && (
|
||||
<div className="md:col-span-2 bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Edit Permissions for {selectedUser.displayName}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Categories Permissions */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-4">
|
||||
Category Permissions
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{categories.map((category) => (
|
||||
<div key={category} className="border rounded-lg p-4">
|
||||
<h4 className="font-medium mb-2">{category}</h4>
|
||||
<div className="space-y-2">
|
||||
{(['create', 'edit', 'delete'] as const).map((action) => (
|
||||
<label
|
||||
key={action}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
selectedUser.permissions.categories[category]?.[action] ?? false
|
||||
}
|
||||
onChange={(e) =>
|
||||
handlePermissionChange(
|
||||
category,
|
||||
action,
|
||||
e.target.checked
|
||||
)
|
||||
}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 capitalize">
|
||||
{action}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cities Access */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-4">
|
||||
City Access
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{cities.map((city) => (
|
||||
<label
|
||||
key={city}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedUser.permissions.cities.includes(city)}
|
||||
onChange={(e) =>
|
||||
handleCityChange(city, e.target.checked)
|
||||
}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{city}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
45
src/services/mockUserService.ts
Normal file
45
src/services/mockUserService.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { User } from '../types/auth';
|
||||
import { Category, City } from '../types';
|
||||
import { mockUsers } from '../data/mockUsers';
|
||||
|
||||
// In-memory storage
|
||||
let users = [...mockUsers];
|
||||
|
||||
export const mockUserService = {
|
||||
getUsers: async (): Promise<User[]> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(users);
|
||||
}, 500); // Simulate network delay
|
||||
});
|
||||
},
|
||||
|
||||
updateUserPermissions: async (userId: string, permissions: User['permissions']): Promise<User> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
const userIndex = users.findIndex(user => user.id === userId);
|
||||
if (userIndex === -1) {
|
||||
reject(new Error('User not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedUser = {
|
||||
...users[userIndex],
|
||||
permissions: {
|
||||
...permissions,
|
||||
// Ensure admin status cannot be changed through this method
|
||||
isAdmin: users[userIndex].permissions.isAdmin
|
||||
}
|
||||
};
|
||||
|
||||
users = [
|
||||
...users.slice(0, userIndex),
|
||||
updatedUser,
|
||||
...users.slice(userIndex + 1)
|
||||
];
|
||||
|
||||
resolve(updatedUser);
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
};
|
24
src/services/userService.ts
Normal file
24
src/services/userService.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import axios from '../utils/api';
|
||||
import { User } from '../types/auth';
|
||||
|
||||
export const userService = {
|
||||
getUsers: async (): Promise<User[]> => {
|
||||
try {
|
||||
const response = await axios.get('/users');
|
||||
return response.data;
|
||||
} 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 response = await axios.put(`/users/${userId}/permissions`, { permissions });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error updating user permissions:', error);
|
||||
throw new Error('Failed to update user permissions');
|
||||
}
|
||||
}
|
||||
};
|
48
src/stores/authStore.ts
Normal file
48
src/stores/authStore.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { create } from 'zustand';
|
||||
import { User } from '../types/auth';
|
||||
import axios from 'axios';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
setUser: (user: User | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
login: (email: string, password: string, tempUser?: User) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
loading: false,
|
||||
setUser: (user) => set({ user }),
|
||||
setLoading: (loading) => set({ loading }),
|
||||
login: async (email: string, password: string, tempUser?: User) => {
|
||||
try {
|
||||
set({ loading: true });
|
||||
|
||||
if (tempUser) {
|
||||
// Use temporary user for development
|
||||
set({ user: tempUser });
|
||||
localStorage.setItem('tempUser', JSON.stringify(tempUser));
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await axios.post('http://localhost:5000/api/auth/login', {
|
||||
email,
|
||||
password
|
||||
});
|
||||
const { user, token } = response.data;
|
||||
localStorage.setItem('token', token);
|
||||
set({ user });
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
logout: () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('tempUser');
|
||||
set({ user: null });
|
||||
}
|
||||
}));
|
20
src/types/auth.ts
Normal file
20
src/types/auth.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Category, City } from './index';
|
||||
|
||||
export interface UserPermissions {
|
||||
categories: {
|
||||
[key in Category]?: {
|
||||
create: boolean;
|
||||
edit: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
};
|
||||
cities: City[];
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
permissions: UserPermissions;
|
||||
}
|
33
src/types/index.ts
Normal file
33
src/types/index.ts
Normal file
@ -0,0 +1,33 @@
|
||||
export interface Article {
|
||||
id: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
category: Category;
|
||||
city: City;
|
||||
author: Author;
|
||||
coverImage: string;
|
||||
gallery?: GalleryImage[];
|
||||
publishedAt: string;
|
||||
readTime: number;
|
||||
likes: number;
|
||||
dislikes: number;
|
||||
userReaction?: 'like' | 'dislike' | null;
|
||||
}
|
||||
|
||||
export interface GalleryImage {
|
||||
id: string;
|
||||
url: string;
|
||||
caption: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
export interface Author {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
bio: string;
|
||||
}
|
||||
|
||||
export type Category = 'Film' | 'Theater' | 'Music' | 'Sports' | 'Art' | 'Legends' | 'Anniversaries' | 'Memory';
|
||||
export type City = 'New York' | 'London';
|
15
src/utils/api.ts
Normal file
15
src/utils/api.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: 'http://localhost:5000/api'
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
export default api;
|
31
src/utils/permissions.ts
Normal file
31
src/utils/permissions.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { Category, City } from '../types';
|
||||
import { User } from '../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);
|
||||
};
|
||||
|
||||
export const getDefaultPermissions = () => ({
|
||||
categories: {
|
||||
Film: { create: false, edit: false, delete: false },
|
||||
Theater: { create: false, edit: false, delete: false },
|
||||
Music: { create: false, edit: false, delete: false },
|
||||
Sports: { create: false, edit: false, delete: false },
|
||||
Art: { create: false, edit: false, delete: false },
|
||||
Legends: { create: false, edit: false, delete: false },
|
||||
Anniversaries: { create: false, edit: false, delete: false },
|
||||
Memory: { create: false, edit: false, delete: false }
|
||||
},
|
||||
cities: [],
|
||||
isAdmin: false
|
||||
});
|
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
8
tailwind.config.js
Normal file
8
tailwind.config.js
Normal file
@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
24
tsconfig.app.json
Normal file
24
tsconfig.app.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
22
tsconfig.node.json
Normal file
22
tsconfig.node.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
17
vite.config.ts
Normal file
17
vite.config.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
optimizeDeps: {
|
||||
exclude: ['@prisma/client', 'lucide-react'],
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user