Добавлены bookmarks. Улучшения интерфейса, логотип. Есть дублирующмй код Header в отдельной папке, разбитый на отдельные компоненты. Сейчас используется монолит.
This commit is contained in:
parent
7fb5daf210
commit
e4d5029e72
30
.nginx/nginx.conf
Normal file
30
.nginx/nginx.conf
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
listen [::]:80 default_server;
|
||||||
|
|
||||||
|
root /var/www/dist;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
server_name russcult.anibilag.ru;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://192.168.1.67:5000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_buffers 16 8k;
|
||||||
|
gzip_http_version 1.1;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
|
||||||
|
error_page 404 /index.html;
|
||||||
|
}
|
BIN
public/images/Logo-1.webp
Normal file
BIN
public/images/Logo-1.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 142 KiB |
BIN
public/images/Logo-2.webp
Normal file
BIN
public/images/Logo-2.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 182 KiB |
BIN
server.zip
BIN
server.zip
Binary file not shown.
@ -13,6 +13,7 @@ import { Footer } from './components/Footer';
|
|||||||
import { AuthGuard } from './components/AuthGuard';
|
import { AuthGuard } from './components/AuthGuard';
|
||||||
import { ImportArticlesPage } from "./pages/ImportArticlesPage";
|
import { ImportArticlesPage } from "./pages/ImportArticlesPage";
|
||||||
|
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000';
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Clock, ThumbsUp, MapPin } from 'lucide-react';
|
import { Clock, ThumbsUp, MapPin, ThumbsDown } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Article, CategoryTitles, CityTitles } from '../types';
|
import { Article, CategoryTitles, CityTitles } from '../types';
|
||||||
import MinutesWord from './MinutesWord';
|
import MinutesWord from './MinutesWord';
|
||||||
@ -9,30 +9,28 @@ interface ArticleCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ArticleCard({ article, featured = false }: ArticleCardProps) {
|
export function ArticleCard({ article, featured = false }: ArticleCardProps) {
|
||||||
const categoryName = CategoryTitles[article.categoryId];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={`overflow-hidden rounded-lg shadow-lg transition-transform hover:scale-[1.02] ${
|
<article className={`overflow-hidden rounded-lg shadow-lg transition-transform hover:scale-[1.02] ${
|
||||||
featured ? 'col-span-2 row-span-2' : ''
|
featured ? 'col-span-2 row-span-2' : ''
|
||||||
}`}>
|
} flex flex-col`}>
|
||||||
<div className="relative pt-7">
|
<div className="relative pt-7">
|
||||||
<img
|
<img
|
||||||
src={article.coverImage}
|
src={article.coverImage}
|
||||||
alt={article.title}
|
alt={article.title}
|
||||||
className={`w-full object-cover ${featured ? 'h-96' : 'h-64'}`}
|
className={`w-full object-cover ${featured ? 'h-96' : 'h-64'}`}
|
||||||
/>
|
/>
|
||||||
<div className="absolute top-1 left-0 flex gap-2 z-10">
|
<div className="absolute bottom-0 left-0 w-full flex justify-between items-center z-10 px-4 py-2 bg-gradient-to-t from-black/70 to-transparent">
|
||||||
<span className="bg-gray-100 shadow px-3 py-1 rounded-full text-sm font-medium">
|
<span className="text-white text-sm md:text-base font-medium">
|
||||||
{categoryName}
|
{CategoryTitles[article.categoryId]}
|
||||||
</span>
|
</span>
|
||||||
<span className="bg-gray-100 shadow px-3 py-1 rounded-full text-sm font-medium flex items-center">
|
<span className="text-white text-sm md:text-base font-medium flex items-center">
|
||||||
<MapPin size={14} className="mr-1" />
|
<MapPin size={14} className="mr-1 text-white" />
|
||||||
{CityTitles[article.cityId]}
|
{CityTitles[article.cityId]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="p-6 flex flex-col flex-grow">
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-center gap-4 mb-4">
|
<div className="flex items-center gap-4 mb-4">
|
||||||
<img
|
<img
|
||||||
src={article.author.avatarUrl}
|
src={article.author.avatarUrl}
|
||||||
@ -58,7 +56,7 @@ export function ArticleCard({ article, featured = false }: ArticleCardProps) {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 line-clamp-2">{article.excerpt}</p>
|
<p className="text-gray-600 line-clamp-2">{article.excerpt}</p>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-between">
|
<div className="p-4 mt-auto flex items-center justify-between">
|
||||||
<Link
|
<Link
|
||||||
to={`/article/${article.id}`}
|
to={`/article/${article.id}`}
|
||||||
className="text-blue-600 font-medium hover:text-blue-800"
|
className="text-blue-600 font-medium hover:text-blue-800"
|
||||||
@ -68,6 +66,10 @@ export function ArticleCard({ article, featured = false }: ArticleCardProps) {
|
|||||||
<div className="flex items-center text-gray-500">
|
<div className="flex items-center text-gray-500">
|
||||||
<ThumbsUp size={16} className="mr-1" />
|
<ThumbsUp size={16} className="mr-1" />
|
||||||
<span>{article.likes}</span>
|
<span>{article.likes}</span>
|
||||||
|
<span className="ml-2">
|
||||||
|
<ThumbsDown size={16} className="mr-1" />
|
||||||
|
</span>
|
||||||
|
<span>{article.dislikes}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -36,7 +36,7 @@ export function AuthGuard({ children, requiredPermissions }: AuthGuardProps) {
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
У вас нет прав на {action} статьи в {categoryId} категории.
|
У вас нет прав на {action} статьи в разделе {categoryId}.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -27,14 +27,14 @@ export function AuthorsSection() {
|
|||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h2 className="text-3xl font-bold text-gray-900">Наши авторы</h2>
|
<h2 className="text-3xl font-bold text-gray-900">Наши авторы</h2>
|
||||||
<p className="mt-4 text-lg text-gray-600 max-w-2xl mx-auto">
|
<p className="mt-4 text-lg text-gray-600 max-w-2xl mx-auto">
|
||||||
Познакомьтесь с талантливыми писателями и экспертами, работающими для вас
|
Познакомьтесь с талантливыми писателями и экспертами, работающими для Вас
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{authors.map((author) => (
|
{authors.map((author) => (
|
||||||
<div key={author.id} className="bg-white rounded-lg shadow-md overflow-hidden transition-transform hover:scale-[1.02]">
|
<div key={author.id} className="bg-white rounded-lg shadow-md overflow-hidden transition-transform hover:scale-[1.02] h-full">
|
||||||
<div className="p-6">
|
<div className="p-6 flex flex-col flex-grow min-h-[200px] h-full">
|
||||||
<div className="flex items-center mb-4">
|
<div className="flex items-center mb-4">
|
||||||
<img
|
<img
|
||||||
src={author.avatarUrl}
|
src={author.avatarUrl}
|
||||||
@ -58,11 +58,11 @@ export function AuthorsSection() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-gray-600">{author.bio}</p>
|
<p className="text-gray-600">{author.bio}</p>
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-gray-100">
|
<div className="mt-auto pt-6 border-t border-gray-100">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-gray-500">
|
||||||
{author.articlesCount} статей
|
{author.articlesCount} статей
|
||||||
</span>
|
</span>
|
||||||
<Link
|
<Link
|
||||||
to={`/search?author=${author.id}`}
|
to={`/search?author=${author.id}`}
|
||||||
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
|
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Bookmark } from 'lucide-react';
|
import { Bookmark } from 'lucide-react';
|
||||||
import { useBookmarkStore } from '../stores/bookmarkStore';
|
import { useBookmarkStore } from '../stores/bookmarkStore';
|
||||||
import { Article } from '../types';
|
import { Article } from '../types';
|
||||||
|
@ -68,7 +68,7 @@ export function FeaturedSection() {
|
|||||||
<div className="flex justify-between items-center mb-8">
|
<div className="flex justify-between items-center mb-8">
|
||||||
<h2 className="text-3xl font-bold text-gray-900">
|
<h2 className="text-3xl font-bold text-gray-900">
|
||||||
{city ? `${CityTitles[Number(city)]} ` : ''}
|
{city ? `${CityTitles[Number(city)]} ` : ''}
|
||||||
{category ? `${CategoryTitles[Number(category)]} Статьи` : 'Тематические статьи'}
|
{category ? `${CategoryTitles[Number(category)]} Статьи` : 'Читайте сегодня'}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="font-bold text-gray-600">
|
<p className="font-bold text-gray-600">
|
||||||
Статьи {Math.min((currentPage - 1) * ARTICLES_PER_PAGE + 1, totalArticles)}
|
Статьи {Math.min((currentPage - 1) * ARTICLES_PER_PAGE + 1, totalArticles)}
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Menu, Search, X, ChevronDown } from 'lucide-react';
|
import { Logo } from "./Header/Logo";
|
||||||
|
import { Menu, Search, X, ChevronDown, Bookmark } from 'lucide-react';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { CategoryIds, CategoryTitles, CityIds, CityTitles } from '../types';
|
import { CategoryIds, CategoryTitles, CityIds, CityTitles } from '../types';
|
||||||
|
import { useBookmarkStore } from "../stores/bookmarkStore";
|
||||||
|
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
@ -12,6 +14,7 @@ export function Header() {
|
|||||||
const searchParams = new URLSearchParams(location.search);
|
const searchParams = new URLSearchParams(location.search);
|
||||||
const currentCategory = searchParams.get('category');
|
const currentCategory = searchParams.get('category');
|
||||||
const currentCity = searchParams.get('city');
|
const currentCity = searchParams.get('city');
|
||||||
|
const { bookmarks } = useBookmarkStore();
|
||||||
|
|
||||||
const handleCityChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
const handleCityChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
const city = event.target.value;
|
const city = event.target.value;
|
||||||
@ -50,9 +53,9 @@ export function Header() {
|
|||||||
>
|
>
|
||||||
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||||
</button>
|
</button>
|
||||||
<Link to="/" className="ml-2 flex items-center space-x-2">
|
<div className="ml-2">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Культура</h1>
|
<Logo />
|
||||||
</Link>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="hidden lg:flex items-center space-x-8">
|
<nav className="hidden lg:flex items-center space-x-8">
|
||||||
@ -80,26 +83,16 @@ export function Header() {
|
|||||||
|
|
||||||
<div className="h-6 w-px bg-gray-200" />
|
<div className="h-6 w-px bg-gray-200" />
|
||||||
|
|
||||||
|
{/* Menu */}
|
||||||
<div className="flex items-center space-x-4 overflow-x-auto">
|
<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'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Все категории
|
|
||||||
</Link>
|
|
||||||
{CategoryIds.map((category) => (
|
{CategoryIds.map((category) => (
|
||||||
<Link
|
<Link
|
||||||
key={category}
|
key={category}
|
||||||
to={`/?category=${category}${currentCity ? `&city=${currentCity}` : ''}`}
|
to={`/?category=${category}${currentCity ? `&city=${currentCity}` : ''}`}
|
||||||
className={`px-3 py-2 text-sm font-medium transition-colors whitespace-nowrap ${
|
className={`px-3 py-2 font-medium transition-colors whitespace-nowrap ${
|
||||||
Number(currentCategory) === category
|
Number(currentCategory) === category
|
||||||
? 'text-blue-600 hover:text-blue-800'
|
? 'text-base md:text-lg lg:text-xl font-semibold text-blue-600 hover:text-blue-800 underline'
|
||||||
: 'text-gray-600 hover:text-gray-900'
|
: 'text-sm md:text-base lg:text-lg font-medium text-gray-600 hover:text-gray-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{CategoryTitles[category]}
|
{CategoryTitles[category]}
|
||||||
@ -112,7 +105,7 @@ export function Header() {
|
|||||||
<form onSubmit={handleSearch} className="relative">
|
<form onSubmit={handleSearch} className="relative">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search..."
|
placeholder="Поиск..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
onKeyDown={handleSearchKeyPress}
|
onKeyDown={handleSearchKeyPress}
|
||||||
@ -125,6 +118,17 @@ export function Header() {
|
|||||||
<Search size={18} />
|
<Search size={18} />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<Link
|
||||||
|
to="/bookmarks"
|
||||||
|
className="relative p-2 rounded-full hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<Bookmark size={20} className="text-gray-500" />
|
||||||
|
{bookmarks.length > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 bg-blue-600 text-white text-xs w-4 h-4 flex items-center justify-center rounded-full">
|
||||||
|
{bookmarks.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -137,7 +141,7 @@ export function Header() {
|
|||||||
>
|
>
|
||||||
<div className="px-2 pt-2 pb-3 space-y-1">
|
<div className="px-2 pt-2 pb-3 space-y-1">
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<h3 className="text-sm font-medium text-gray-500 mb-2">City</h3>
|
<h3 className="text-sm font-medium text-gray-500 mb-2">Столицы</h3>
|
||||||
<select
|
<select
|
||||||
value={currentCity || ''}
|
value={currentCity || ''}
|
||||||
onChange={handleCityChange}
|
onChange={handleCityChange}
|
||||||
@ -153,18 +157,8 @@ export function Header() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<h3 className="text-sm font-medium text-gray-500 mb-2">Categories</h3>
|
<h3 className="text-sm font-medium text-gray-500 mb-2">Разделы</h3>
|
||||||
<div className="space-y-1">
|
<div className="space-y-0">
|
||||||
<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'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Все категории
|
|
||||||
</Link>
|
|
||||||
{CategoryIds.map((category) => (
|
{CategoryIds.map((category) => (
|
||||||
<Link
|
<Link
|
||||||
key={category}
|
key={category}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { ChevronDown } from 'lucide-react';
|
import { ChevronDown } from 'lucide-react';
|
||||||
import { City } from '../../types';
|
import { CityTitles } from '../../types';
|
||||||
|
|
||||||
interface CitySelectorProps {
|
interface CitySelectorProps {
|
||||||
cities: City[];
|
cities: number[];
|
||||||
currentCity: string | null;
|
currentCity: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,7 +34,7 @@ export function CitySelector({ cities, currentCity }: CitySelectorProps) {
|
|||||||
<option value="">All Cities</option>
|
<option value="">All Cities</option>
|
||||||
{cities.map((city) => (
|
{cities.map((city) => (
|
||||||
<option key={city} value={city}>
|
<option key={city} value={city}>
|
||||||
{city}
|
{CityTitles[city]}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Palette } from 'lucide-react';
|
|
||||||
|
|
||||||
export function Logo() {
|
export function Logo() {
|
||||||
return (
|
return (
|
||||||
<Link to="/" className="flex items-center space-x-2 text-gray-900">
|
<Link to="/" className="flex items-center space-x-4 text-gray-900 min-w-0">
|
||||||
<Palette size={32} className="text-blue-600" />
|
<img
|
||||||
<span className="text-2xl font-bold">CultureScope</span>
|
src="/images/Logo-2.webp"
|
||||||
</Link>
|
alt="Культура двух столиц"
|
||||||
|
className="h-10 w-10 md:h-12 md:w-12 flex-shrink-0 transition-transform duration-300 hover:scale-150"
|
||||||
|
/>
|
||||||
|
<span className="invisible">_____</span>
|
||||||
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -1,11 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Category, City } from '../../types';
|
import { CityTitles, CategoryTitles } from '../../types';
|
||||||
|
|
||||||
interface MobileMenuProps {
|
interface MobileMenuProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
categories: Category[];
|
categories: number[];
|
||||||
cities: City[];
|
cities: number[];
|
||||||
currentCategory: string | null;
|
currentCategory: string | null;
|
||||||
currentCity: string | null;
|
currentCity: string | null;
|
||||||
onCityChange: (event: React.ChangeEvent<HTMLSelectElement>) => void;
|
onCityChange: (event: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||||
@ -34,7 +34,7 @@ export function MobileMenu({
|
|||||||
<option value="">All Cities</option>
|
<option value="">All Cities</option>
|
||||||
{cities.map((city) => (
|
{cities.map((city) => (
|
||||||
<option key={city} value={city}>
|
<option key={city} value={city}>
|
||||||
{city}
|
{CityTitles[city]}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@ -58,12 +58,12 @@ export function MobileMenu({
|
|||||||
key={category}
|
key={category}
|
||||||
to={`/?category=${category}${currentCity ? `&city=${currentCity}` : ''}`}
|
to={`/?category=${category}${currentCity ? `&city=${currentCity}` : ''}`}
|
||||||
className={`block px-3 py-2 rounded-md text-base font-medium ${
|
className={`block px-3 py-2 rounded-md text-base font-medium ${
|
||||||
currentCategory === category
|
Number(currentCategory) === category
|
||||||
? 'bg-blue-50 text-blue-600'
|
? 'bg-blue-50 text-blue-600'
|
||||||
: 'text-gray-600 hover:bg-gray-50'
|
: 'text-gray-600 hover:bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{category}
|
{CategoryTitles[category]}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Category } from '../../types';
|
import { CategoryTitles } from '../../types';
|
||||||
|
|
||||||
interface NavigationProps {
|
interface NavigationProps {
|
||||||
categories: Category[];
|
categories: number[];
|
||||||
currentCategory: string | null;
|
currentCategory: string | null;
|
||||||
currentCity: string | null;
|
currentCity: string | null;
|
||||||
}
|
}
|
||||||
@ -26,12 +26,12 @@ export function Navigation({ categories, currentCategory, currentCity }: Navigat
|
|||||||
key={category}
|
key={category}
|
||||||
to={`/?category=${category}${currentCity ? `&city=${currentCity}` : ''}`}
|
to={`/?category=${category}${currentCity ? `&city=${currentCity}` : ''}`}
|
||||||
className={`px-3 py-2 text-sm font-medium transition-colors whitespace-nowrap ${
|
className={`px-3 py-2 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
currentCategory === category
|
Number(currentCategory) === category
|
||||||
? 'text-blue-600 hover:text-blue-800'
|
? 'text-blue-600 hover:text-blue-800'
|
||||||
: 'text-gray-600 hover:text-gray-900'
|
: 'text-gray-600 hover:text-gray-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{category}
|
{CategoryTitles[category]}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,16 +1,14 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Menu, X, Bookmark } from 'lucide-react';
|
import { Menu, X, Bookmark } from 'lucide-react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { Category, City } from '../../types';
|
|
||||||
import { Logo } from './Logo';
|
import { Logo } from './Logo';
|
||||||
import { SearchBar } from './SearchBar';
|
import { SearchBar } from './SearchBar';
|
||||||
import { Navigation } from './Navigation';
|
import { Navigation } from './Navigation';
|
||||||
import { CitySelector } from './CitySelector';
|
import { CitySelector } from './CitySelector';
|
||||||
import { MobileMenu } from './MobileMenu';
|
import { MobileMenu } from './MobileMenu';
|
||||||
|
import { CategoryIds, CityIds } from '../../types';
|
||||||
import { useBookmarkStore } from '../../stores/bookmarkStore';
|
import { useBookmarkStore } from '../../stores/bookmarkStore';
|
||||||
|
|
||||||
const categories: Category[] = ['Film', 'Theater', 'Music', 'Sports', 'Art', 'Legends', 'Anniversaries', 'Memory'];
|
|
||||||
const cities: City[] = ['New York', 'London'];
|
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
@ -52,13 +50,13 @@ export function Header() {
|
|||||||
<nav className="hidden lg:flex items-center space-x-8">
|
<nav className="hidden lg:flex items-center space-x-8">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<span className="text-sm font-medium text-gray-500"></span>
|
<span className="text-sm font-medium text-gray-500"></span>
|
||||||
<CitySelector cities={cities} currentCity={currentCity} />
|
<CitySelector cities={CityIds} currentCity={currentCity} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-6 w-px bg-gray-200" />
|
<div className="h-6 w-px bg-gray-200" />
|
||||||
|
|
||||||
<Navigation
|
<Navigation
|
||||||
categories={categories}
|
categories={CategoryIds}
|
||||||
currentCategory={currentCategory}
|
currentCategory={currentCategory}
|
||||||
currentCity={currentCity}
|
currentCity={currentCity}
|
||||||
/>
|
/>
|
||||||
@ -83,8 +81,8 @@ export function Header() {
|
|||||||
|
|
||||||
<MobileMenu
|
<MobileMenu
|
||||||
isOpen={isMobileMenuOpen}
|
isOpen={isMobileMenuOpen}
|
||||||
categories={categories}
|
categories={CategoryIds}
|
||||||
cities={cities}
|
cities={CityIds}
|
||||||
currentCategory={currentCategory}
|
currentCategory={currentCategory}
|
||||||
currentCity={currentCity}
|
currentCity={currentCity}
|
||||||
onCityChange={handleCityChange}
|
onCityChange={handleCityChange}
|
||||||
|
46
src/components/ShareButton.tsx
Normal file
46
src/components/ShareButton.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Share2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ShareButtonProps {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShareButton({ title, url, className = '' }: ShareButtonProps) {
|
||||||
|
const [showTooltip, setShowTooltip] = useState(false);
|
||||||
|
|
||||||
|
const handleShare = async () => {
|
||||||
|
try {
|
||||||
|
if (navigator.share) {
|
||||||
|
await navigator.share({
|
||||||
|
title,
|
||||||
|
url
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setShowTooltip(true);
|
||||||
|
setTimeout(() => setShowTooltip(false), 2000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sharing:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={handleShare}
|
||||||
|
className={`p-2 text-gray-500 hover:text-gray-700 rounded-full hover:bg-gray-100 ${className}`}
|
||||||
|
aria-label="Share article"
|
||||||
|
>
|
||||||
|
<Share2 size={20} />
|
||||||
|
</button>
|
||||||
|
{showTooltip && (
|
||||||
|
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-gray-800 text-white text-xs rounded whitespace-nowrap">
|
||||||
|
Ссылка скопированна!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,11 +1,13 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { ArrowLeft, Clock, Share2, Bookmark } from 'lucide-react';
|
import { ArrowLeft, Clock } from 'lucide-react';
|
||||||
import { Header } from '../components/Header';
|
import { Header } from '../components/Header';
|
||||||
import { ReactionButtons } from '../components/ReactionButtons';
|
import { ReactionButtons } from '../components/ReactionButtons';
|
||||||
import { PhotoGallery } from '../components/PhotoGallery';
|
import { PhotoGallery } from '../components/PhotoGallery';
|
||||||
import { Article, CategoryTitles } from '../types';
|
import { Article, CategoryTitles } from '../types';
|
||||||
import { ArticleContent } from '../components/ArticleContent';
|
import { ArticleContent } from '../components/ArticleContent';
|
||||||
|
import { ShareButton } from '../components/ShareButton';
|
||||||
|
import { BookmarkButton } from '../components/BookmarkButton';
|
||||||
import MinutesWord from '../components/MinutesWord';
|
import MinutesWord from '../components/MinutesWord';
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import api from "../utils/api";
|
import api from "../utils/api";
|
||||||
@ -122,12 +124,8 @@ export function ArticlePage() {
|
|||||||
{CategoryTitles[articleData.categoryId]}
|
{CategoryTitles[articleData.categoryId]}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button className="p-2 text-gray-500 hover:text-gray-700 rounded-full hover:bg-gray-100">
|
<ShareButton title={articleData.title} url={window.location.href} />
|
||||||
<Share2 size={20} />
|
<BookmarkButton article={articleData} />
|
||||||
</button>
|
|
||||||
<button className="p-2 text-gray-500 hover:text-gray-700 rounded-full hover:bg-gray-100">
|
|
||||||
<Bookmark size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ export function BookmarksPage() {
|
|||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
<div className="flex items-center space-x-4 mb-8">
|
<div className="flex items-center space-x-4 mb-8">
|
||||||
<Bookmark size={24} className="text-blue-600" />
|
<Bookmark size={24} className="text-blue-600" />
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Your Bookmarks</h1>
|
<h1 className="text-3xl font-bold text-gray-900">Мои закладки</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{bookmarks.length > 0 ? (
|
{bookmarks.length > 0 ? (
|
||||||
|
@ -30,49 +30,50 @@ export function HomePage() {
|
|||||||
const { main, sub, description } = getHeroTitle();
|
const { main, sub, description } = getHeroTitle();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<div className="min-h-screen bg-cover bg-center bg-fixed bg-white/95" style={{ backgroundImage: `url('/images/main-bg.jpg')` }}>
|
<div className="min-h-screen bg-cover bg-center bg-fixed bg-white/95" style={{ backgroundImage: `url('/images/main-bg.jpg')` }}>
|
||||||
<Header />
|
<Header />
|
||||||
<main>
|
<main>
|
||||||
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
<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="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="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">
|
<div className="absolute inset-0">
|
||||||
<span className="block mb-2">{main}</span>
|
<div
|
||||||
<span className="block text-blue-400">{sub}</span>
|
className="w-full h-full bg-cover bg-center transition-all duration-500 ease-in-out"
|
||||||
</h1>
|
style={{
|
||||||
<p className="mt-6 text-xl text-gray-200 max-w-2xl mx-auto">
|
backgroundImage: `url("${backgroundImage}")`,
|
||||||
{description}
|
}}
|
||||||
</p>
|
>
|
||||||
<div className="mt-8 flex justify-center space-x-4">
|
<div className="absolute inset-0 bg-gradient-to-r from-gray-900/90 to-gray-900/60 backdrop-blur-sm" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative bg-white/95">
|
<div className="relative">
|
||||||
<div id="featured">
|
<div className="text-center max-w-4xl mx-auto py-24 px-4 sm:py-32 sm:px-6 lg:px-8">
|
||||||
<FeaturedSection />
|
<h1 className="text-4xl font-extrabold tracking-tight text-white sm:text-5xl md:text-6xl">
|
||||||
</div>
|
<span className="block mb-2">{main}</span>
|
||||||
<AuthorsSection />
|
<span className="block text-blue-400">{sub}</span>
|
||||||
</div>
|
</h1>
|
||||||
|
<p className="mt-6 text-xl text-gray-200 max-w-2xl mx-auto">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex justify-center space-x-4">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative bg-white/95">
|
||||||
|
<div id="featured">
|
||||||
|
<FeaturedSection />
|
||||||
|
</div>
|
||||||
|
<AuthorsSection />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -6,6 +6,9 @@ export default defineConfig(({ mode }) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
esbuild: {
|
||||||
|
minifyIdentifiers: false, // Отключаем переименование переменных
|
||||||
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
exclude: ['@prisma/client', 'lucide-react'],
|
exclude: ['@prisma/client', 'lucide-react'],
|
||||||
},
|
},
|
||||||
|
Loading…
x
Reference in New Issue
Block a user