first commit

This commit is contained in:
anibilag 2024-12-09 16:06:47 +03:00
commit 036d28cff9
62 changed files with 9597 additions and 0 deletions

3
.bolt/config.json Normal file
View File

@ -0,0 +1,3 @@
{
"template": "bolt-vite-react-ts"
}

8
.bolt/prompt Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View 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
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View 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;

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;

View 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;
}
};

View 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
View 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;

View 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>
);
}

View 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}</>;
}

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
}
}
];

View 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;
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);
}

View 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>
);
}

View 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);
});
}
};

View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

8
tailwind.config.js Normal file
View 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
View 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
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
tsconfig.node.json Normal file
View 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
View 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,
},
},
},
});