updates
This commit is contained in:
416
Frontend/src/pages/BlogDetailPage.tsx
Normal file
416
Frontend/src/pages/BlogDetailPage.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { Calendar, User, Tag, ArrowLeft, Eye, Share2, ArrowRight } from 'lucide-react';
|
||||
import { blogService, BlogPost } from '../services/api';
|
||||
import Loading from '../components/common/Loading';
|
||||
import { createSanitizedHtml } from '../utils/htmlSanitizer';
|
||||
|
||||
const BlogDetailPage: React.FC = () => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [post, setPost] = useState<BlogPost | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [relatedPosts, setRelatedPosts] = useState<BlogPost[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (slug) {
|
||||
fetchPost();
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
const fetchPost = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await blogService.getBlogPostBySlug(slug!);
|
||||
|
||||
if (response.status === 'success' && response.data) {
|
||||
const postData = response.data.post;
|
||||
setPost(postData);
|
||||
|
||||
// Set meta tags
|
||||
if (postData.meta_title) {
|
||||
document.title = postData.meta_title;
|
||||
}
|
||||
if (postData.meta_description) {
|
||||
let metaDescription = document.querySelector('meta[name="description"]');
|
||||
if (!metaDescription) {
|
||||
metaDescription = document.createElement('meta');
|
||||
metaDescription.setAttribute('name', 'description');
|
||||
document.head.appendChild(metaDescription);
|
||||
}
|
||||
metaDescription.setAttribute('content', postData.meta_description);
|
||||
}
|
||||
|
||||
// Fetch related posts
|
||||
if (postData.tags && postData.tags.length > 0) {
|
||||
fetchRelatedPosts(postData.tags[0]);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching blog post:', error);
|
||||
if (error.response?.status === 404) {
|
||||
navigate('/blog');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRelatedPosts = async (tag: string) => {
|
||||
try {
|
||||
const response = await blogService.getBlogPosts({
|
||||
page: 1,
|
||||
limit: 3,
|
||||
tag,
|
||||
published_only: true,
|
||||
});
|
||||
|
||||
if (response.status === 'success' && response.data) {
|
||||
// Filter out current post
|
||||
const related = response.data.posts.filter((p: BlogPost) => p.slug !== slug);
|
||||
setRelatedPosts(related.slice(0, 3));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching related posts:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '';
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
if (navigator.share && post) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: post.title,
|
||||
text: post.excerpt,
|
||||
url: window.location.href,
|
||||
});
|
||||
} catch (error) {
|
||||
// User cancelled or error occurred
|
||||
}
|
||||
} else {
|
||||
// Fallback: copy to clipboard
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
alert('Link copied to clipboard!');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (!post) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-black text-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">Post not found</h1>
|
||||
<Link to="/blog" className="text-[#d4af37] hover:underline">
|
||||
Back to Blog
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-full" style={{ width: '100vw', position: 'relative', left: '50%', right: '50%', marginLeft: '-50vw', marginRight: '-50vw', marginTop: '-1.5rem', marginBottom: '-1.5rem' }}>
|
||||
{/* Hero Section with Featured Image */}
|
||||
{post.featured_image && (
|
||||
<div className="relative w-full h-64 sm:h-80 lg:h-96 overflow-hidden">
|
||||
<img
|
||||
src={post.featured_image}
|
||||
alt={post.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="w-full py-8 sm:py-12 lg:py-16">
|
||||
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Back Button */}
|
||||
<Link
|
||||
to="/blog"
|
||||
className="inline-flex items-center gap-2 text-gray-400 hover:text-[#d4af37] transition-colors mb-8"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Back to Blog</span>
|
||||
</Link>
|
||||
|
||||
{/* Article Header */}
|
||||
<div className="mb-8">
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{post.tags.map((tag) => (
|
||||
<Link
|
||||
key={tag}
|
||||
to={`/blog?tag=${encodeURIComponent(tag)}`}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 bg-[#d4af37]/10 text-[#d4af37] rounded-full text-sm font-medium hover:bg-[#d4af37]/20 transition-colors"
|
||||
>
|
||||
<Tag className="w-3 h-3" />
|
||||
{tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-display font-bold text-white mb-6 leading-tight">
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
{post.excerpt && (
|
||||
<p className="text-xl text-gray-300 mb-6 font-light leading-relaxed">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-6 text-sm text-gray-400 pb-6 border-b border-[#d4af37]/20">
|
||||
{post.published_at && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{formatDate(post.published_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
{post.author_name && (
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
<span>{post.author_name}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="w-4 h-4" />
|
||||
<span>{post.views_count} views</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="flex items-center gap-2 text-[#d4af37] hover:text-[#f5d76e] transition-colors"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Article Content */}
|
||||
<article className="prose prose-invert prose-lg max-w-none mb-12
|
||||
prose-headings:text-white prose-headings:font-elegant prose-headings:font-semibold
|
||||
prose-h2:text-3xl prose-h2:mt-12 prose-h2:mb-6 prose-h2:border-b prose-h2:border-[#d4af37]/20 prose-h2:pb-3
|
||||
prose-h3:text-2xl prose-h3:mt-8 prose-h3:mb-4
|
||||
prose-p:text-gray-300 prose-p:font-light prose-p:leading-relaxed prose-p:mb-6
|
||||
prose-ul:text-gray-300 prose-ul:font-light prose-ul:my-6
|
||||
prose-ol:text-gray-300 prose-ol:font-light prose-ol:my-6
|
||||
prose-li:text-gray-300 prose-li:mb-2
|
||||
prose-strong:text-[#d4af37] prose-strong:font-medium
|
||||
prose-a:text-[#d4af37] prose-a:no-underline hover:prose-a:underline
|
||||
prose-img:rounded-xl prose-img:shadow-2xl
|
||||
[&_*]:text-gray-300 [&_h1]:text-white [&_h2]:text-white [&_h3]:text-white [&_h4]:text-white
|
||||
[&_strong]:text-[#d4af37] [&_b]:text-[#d4af37] [&_a]:text-[#d4af37]"
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={createSanitizedHtml(
|
||||
post.content || '<p>No content available.</p>'
|
||||
)}
|
||||
/>
|
||||
</article>
|
||||
|
||||
{/* Luxury Sections */}
|
||||
{post.sections && post.sections.length > 0 && (
|
||||
<div className="space-y-16 mb-12">
|
||||
{post.sections
|
||||
.filter((section) => section.is_visible !== false) // Only show visible sections
|
||||
.map((section, index) => (
|
||||
<div key={index}>
|
||||
{/* Hero Section */}
|
||||
{section.type === 'hero' && (
|
||||
<div className="relative rounded-3xl overflow-hidden border-2 border-[#d4af37]/20 shadow-2xl">
|
||||
{section.image && (
|
||||
<div className="absolute inset-0">
|
||||
<img src={section.image} alt={section.title} className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent"></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative px-8 py-16 md:px-16 md:py-24 text-center">
|
||||
{section.title && (
|
||||
<h2 className="text-4xl md:text-5xl lg:text-6xl font-serif font-bold text-white mb-6">
|
||||
{section.title}
|
||||
</h2>
|
||||
)}
|
||||
{section.content && (
|
||||
<p className="text-xl md:text-2xl text-gray-200 font-light leading-relaxed max-w-3xl mx-auto">
|
||||
{section.content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text Section */}
|
||||
{section.type === 'text' && (
|
||||
<div className={`bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] rounded-3xl border-2 border-[#d4af37]/20 p-8 md:p-12 shadow-xl ${
|
||||
section.alignment === 'center' ? 'text-center' : section.alignment === 'right' ? 'text-right' : 'text-left'
|
||||
}`}>
|
||||
{section.title && (
|
||||
<h3 className="text-3xl md:text-4xl font-serif font-bold text-white mb-6">
|
||||
{section.title}
|
||||
</h3>
|
||||
)}
|
||||
{section.content && (
|
||||
<div
|
||||
className="text-gray-300 font-light leading-relaxed text-lg"
|
||||
dangerouslySetInnerHTML={createSanitizedHtml(section.content)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Section */}
|
||||
{section.type === 'image' && section.image && (
|
||||
<div className="rounded-3xl overflow-hidden border-2 border-[#d4af37]/20 shadow-2xl">
|
||||
<img src={section.image} alt={section.title || 'Blog image'} className="w-full h-auto" />
|
||||
{section.title && (
|
||||
<div className="bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] px-6 py-4 border-t border-[#d4af37]/20">
|
||||
<p className="text-gray-400 text-sm font-light italic text-center">{section.title}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gallery Section */}
|
||||
{section.type === 'gallery' && section.images && section.images.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{section.images.map((img, imgIndex) => (
|
||||
<div key={imgIndex} className="rounded-2xl overflow-hidden border-2 border-[#d4af37]/20 shadow-xl group hover:border-[#d4af37]/50 transition-all">
|
||||
<img src={img} alt={`Gallery image ${imgIndex + 1}`} className="w-full h-64 object-cover group-hover:scale-110 transition-transform duration-500" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quote Section */}
|
||||
{section.type === 'quote' && (
|
||||
<div className="relative bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] rounded-3xl border-2 border-[#d4af37]/30 p-8 md:p-12 shadow-2xl">
|
||||
<div className="absolute top-6 left-6 text-6xl text-[#d4af37]/20 font-serif">"</div>
|
||||
{section.quote && (
|
||||
<blockquote className="text-2xl md:text-3xl font-serif font-light text-white italic mb-6 relative z-10 pl-8">
|
||||
{section.quote}
|
||||
</blockquote>
|
||||
)}
|
||||
{section.author && (
|
||||
<cite className="text-[#d4af37] text-lg font-medium not-italic block text-right">
|
||||
— {section.author}
|
||||
</cite>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Features Section */}
|
||||
{section.type === 'features' && section.features && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{section.features.map((feature, featIndex) => (
|
||||
<div key={featIndex} className="bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] rounded-2xl border-2 border-[#d4af37]/20 p-6 shadow-xl hover:border-[#d4af37]/50 transition-all">
|
||||
{feature.icon && (
|
||||
<div className="text-4xl mb-4">{feature.icon}</div>
|
||||
)}
|
||||
<h4 className="text-xl font-bold text-white mb-3">{feature.title}</h4>
|
||||
<p className="text-gray-300 font-light leading-relaxed">{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA Section */}
|
||||
{section.type === 'cta' && (
|
||||
<div className="relative bg-gradient-to-br from-[#d4af37]/10 via-[#c9a227]/5 to-[#d4af37]/10 rounded-3xl border-2 border-[#d4af37]/30 p-8 md:p-12 text-center shadow-2xl">
|
||||
{section.title && (
|
||||
<h3 className="text-3xl md:text-4xl font-serif font-bold text-white mb-4">
|
||||
{section.title}
|
||||
</h3>
|
||||
)}
|
||||
{section.content && (
|
||||
<p className="text-xl text-gray-300 font-light mb-8 max-w-2xl mx-auto">
|
||||
{section.content}
|
||||
</p>
|
||||
)}
|
||||
{section.cta_text && section.cta_link && (
|
||||
<a
|
||||
href={section.cta_link}
|
||||
className="inline-flex items-center gap-3 px-8 py-4 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] font-semibold rounded-xl hover:from-[#f5d76e] hover:to-[#d4af37] transition-all shadow-lg hover:shadow-xl hover:scale-105"
|
||||
>
|
||||
{section.cta_text}
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video Section */}
|
||||
{section.type === 'video' && section.video_url && (
|
||||
<div className="rounded-3xl overflow-hidden border-2 border-[#d4af37]/20 shadow-2xl bg-black">
|
||||
<div className="aspect-video">
|
||||
<iframe
|
||||
src={section.video_url.replace('watch?v=', 'embed/').replace('vimeo.com/', 'player.vimeo.com/video/')}
|
||||
className="w-full h-full"
|
||||
allowFullScreen
|
||||
title="Blog video"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Related Posts */}
|
||||
{relatedPosts.length > 0 && (
|
||||
<div className="mt-16 pt-12 border-t border-[#d4af37]/20">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-white mb-8">Related Posts</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{relatedPosts.map((relatedPost) => (
|
||||
<Link
|
||||
key={relatedPost.id}
|
||||
to={`/blog/${relatedPost.slug}`}
|
||||
className="group bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] rounded-xl border border-[#d4af37]/20 overflow-hidden hover:border-[#d4af37]/50 transition-all duration-300"
|
||||
>
|
||||
{relatedPost.featured_image && (
|
||||
<div className="relative h-40 overflow-hidden">
|
||||
<img
|
||||
src={relatedPost.featured_image}
|
||||
alt={relatedPost.title}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-bold text-white mb-2 group-hover:text-[#d4af37] transition-colors line-clamp-2">
|
||||
{relatedPost.title}
|
||||
</h3>
|
||||
{relatedPost.excerpt && (
|
||||
<p className="text-gray-400 text-sm line-clamp-2 font-light">
|
||||
{relatedPost.excerpt}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogDetailPage;
|
||||
|
||||
321
Frontend/src/pages/BlogPage.tsx
Normal file
321
Frontend/src/pages/BlogPage.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Calendar, User, Tag, Search, ArrowRight, Eye, Sparkles, BookOpen } from 'lucide-react';
|
||||
import { blogService, BlogPost } from '../services/api';
|
||||
import Loading from '../components/common/Loading';
|
||||
import Pagination from '../components/common/Pagination';
|
||||
|
||||
const BlogPage: React.FC = () => {
|
||||
const [posts, setPosts] = useState<BlogPost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||
const [allTags, setAllTags] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPosts();
|
||||
}, [currentPage, searchTerm, selectedTag]);
|
||||
|
||||
const fetchPosts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await blogService.getBlogPosts({
|
||||
page: currentPage,
|
||||
limit: 10,
|
||||
search: searchTerm || undefined,
|
||||
tag: selectedTag || undefined,
|
||||
published_only: true,
|
||||
});
|
||||
|
||||
if (response.status === 'success' && response.data) {
|
||||
setPosts(response.data.posts);
|
||||
setTotalPages(response.data.pagination.pages);
|
||||
setTotal(response.data.pagination.total);
|
||||
|
||||
// Use all_tags from API response if available, otherwise extract from current page
|
||||
if (response.data.all_tags && Array.isArray(response.data.all_tags)) {
|
||||
setAllTags(response.data.all_tags);
|
||||
} else {
|
||||
// Fallback: extract tags from current page posts
|
||||
const tags = new Set<string>();
|
||||
response.data.posts.forEach((post: BlogPost) => {
|
||||
post.tags?.forEach(tag => tags.add(tag));
|
||||
});
|
||||
setAllTags(Array.from(tags));
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching blog posts:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '';
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
if (loading && posts.length === 0) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-full" style={{ width: '100vw', position: 'relative', left: '50%', right: '50%', marginLeft: '-50vw', marginRight: '-50vw', marginTop: '-1.5rem', marginBottom: '-1.5rem' }}>
|
||||
{/* Hero Section */}
|
||||
<div className="w-full bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a] border-b border-[#d4af37]/10 pt-6 sm:pt-7 md:pt-8 overflow-hidden relative">
|
||||
{/* Background Effects */}
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-10 left-10 w-32 sm:w-48 h-32 sm:h-48 bg-[#d4af37] rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-10 right-10 w-40 sm:w-64 h-40 sm:h-64 bg-[#c9a227] rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[#d4af37]/50 to-transparent"></div>
|
||||
|
||||
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20 py-4 sm:py-5 md:py-6 relative z-10">
|
||||
<div className="max-w-2xl mx-auto text-center px-2">
|
||||
<div className="flex justify-center mb-2 sm:mb-3 md:mb-4">
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37] via-[#f5d76e] to-[#c9a227] rounded-xl blur-lg opacity-40 group-hover:opacity-60 transition-opacity duration-500"></div>
|
||||
<div className="relative p-2 sm:p-2.5 md:p-3 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border-2 border-[#d4af37]/40 backdrop-blur-sm shadow-xl shadow-[#d4af37]/20 group-hover:border-[#d4af37]/60 transition-all duration-300">
|
||||
<BookOpen className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-[#d4af37] drop-shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl xs:text-3xl sm:text-4xl md:text-5xl font-serif font-semibold mb-2 sm:mb-3 tracking-tight leading-tight px-2">
|
||||
<span className="bg-gradient-to-r from-white via-[#d4af37] to-white bg-clip-text text-transparent">
|
||||
Our Blog
|
||||
</span>
|
||||
</h1>
|
||||
<div className="w-12 sm:w-16 md:w-20 h-0.5 bg-gradient-to-r from-transparent via-[#d4af37] to-transparent mx-auto mb-2 sm:mb-3"></div>
|
||||
<p className="text-sm sm:text-base md:text-lg text-gray-300 font-light leading-relaxed max-w-xl mx-auto tracking-wide px-2 sm:px-4">
|
||||
Discover stories, insights, and updates from our luxury hotel
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-center gap-2 text-[#d4af37]/60">
|
||||
<Sparkles className="w-4 h-4 animate-pulse" />
|
||||
<span className="text-xs sm:text-sm font-light tracking-wider uppercase">Premium Content</span>
|
||||
<Sparkles className="w-4 h-4 animate-pulse" style={{ animationDelay: '0.5s' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[#d4af37]/30 to-transparent"></div>
|
||||
</div>
|
||||
|
||||
{/* Main Content - Full Width */}
|
||||
<div className="w-full py-12 sm:py-16 lg:py-20">
|
||||
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20">
|
||||
{/* Search Section */}
|
||||
<div className="mb-12 sm:mb-16">
|
||||
<div className="flex flex-col lg:flex-row gap-6 mb-8">
|
||||
<div className="flex-1 relative group">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/10 to-transparent rounded-xl blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-5 top-1/2 transform -translate-y-1/2 text-[#d4af37] w-5 h-5 z-10" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search blog posts..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full pl-14 pr-5 py-4 bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] border border-[#d4af37]/20 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37]/50 transition-all duration-300 backdrop-blur-sm font-light"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Layout: Posts on Left, Tags on Right */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12">
|
||||
{/* Blog Posts Section */}
|
||||
<div className="lg:col-span-9">
|
||||
|
||||
{/* Results Count */}
|
||||
{!loading && total > 0 && (
|
||||
<div className="mb-8 text-gray-400 font-light text-sm">
|
||||
Showing {posts.length} of {total} posts
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Blog Posts Grid - Luxury Design */}
|
||||
{posts.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-[#d4af37]/10 mb-6">
|
||||
<BookOpen className="w-10 h-10 text-[#d4af37]" />
|
||||
</div>
|
||||
<p className="text-gray-400 text-xl font-light">No blog posts found</p>
|
||||
<p className="text-gray-500 text-sm mt-2">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 lg:gap-10 mb-12">
|
||||
{posts.map((post, index) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
to={`/blog/${post.slug}`}
|
||||
className="group relative bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#0a0a0a] rounded-3xl border-2 border-[#d4af37]/20 overflow-hidden hover:border-[#d4af37]/60 transition-all duration-700 hover:shadow-2xl hover:shadow-[#d4af37]/30 hover:-translate-y-3"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
{/* Premium Glow Effects */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37]/0 via-[#d4af37]/0 to-[#d4af37]/0 group-hover:from-[#d4af37]/10 group-hover:via-[#d4af37]/5 group-hover:to-[#d4af37]/10 transition-all duration-700 rounded-3xl blur-2xl opacity-0 group-hover:opacity-100"></div>
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-[#d4af37]/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10">
|
||||
{post.featured_image && (
|
||||
<div className="relative h-72 sm:h-80 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent z-10"></div>
|
||||
<img
|
||||
src={post.featured_image}
|
||||
alt={post.title}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-1000 ease-out"
|
||||
/>
|
||||
{/* Premium Badge Overlay */}
|
||||
<div className="absolute top-6 left-6 z-20">
|
||||
<div className="bg-gradient-to-br from-[#d4af37]/20 to-[#c9a227]/10 backdrop-blur-md rounded-2xl px-4 py-2 border border-[#d4af37]/40 shadow-xl">
|
||||
<div className="flex items-center gap-2 text-[#d4af37]">
|
||||
<Eye className="w-4 h-4" />
|
||||
<span className="text-sm font-semibold">{post.views_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Luxury Corner Accent */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-[#d4af37]/20 to-transparent rounded-bl-full opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-8">
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-5">
|
||||
{post.tags.slice(0, 2).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-1.5 bg-gradient-to-br from-[#d4af37]/15 to-[#d4af37]/5 text-[#d4af37] rounded-full text-xs font-semibold border border-[#d4af37]/30 backdrop-blur-sm shadow-lg"
|
||||
>
|
||||
<Tag className="w-3 h-3" />
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-2xl sm:text-3xl font-serif font-bold text-white mb-4 group-hover:text-[#d4af37] transition-colors duration-500 line-clamp-2 leading-tight tracking-tight">
|
||||
{post.title}
|
||||
</h2>
|
||||
{post.excerpt && (
|
||||
<p className="text-gray-300 text-base mb-6 line-clamp-3 font-light leading-relaxed">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-sm text-gray-400 pt-6 border-t border-[#d4af37]/20 mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
{post.published_at && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-[#d4af37]" />
|
||||
<span className="font-light">{formatDate(post.published_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{post.author_name && (
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-[#d4af37]" />
|
||||
<span className="font-light">{post.author_name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-4 border-t border-[#d4af37]/10">
|
||||
<div className="flex items-center gap-3 text-[#d4af37] group-hover:gap-4 transition-all duration-300">
|
||||
<span className="text-sm font-semibold tracking-wide uppercase">Read Article</span>
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-2 transition-transform duration-300" />
|
||||
</div>
|
||||
<div className="w-12 h-0.5 bg-gradient-to-r from-[#d4af37] to-transparent group-hover:w-20 transition-all duration-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-16 flex justify-center">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags Sidebar - Right Side */}
|
||||
<div className="lg:col-span-3">
|
||||
{allTags.length > 0 && (
|
||||
<div className="sticky top-8">
|
||||
<div className="bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#0a0a0a] rounded-3xl border-2 border-[#d4af37]/20 p-8 backdrop-blur-xl shadow-2xl">
|
||||
<div className="flex items-center gap-3 mb-6 pb-6 border-b border-[#d4af37]/20">
|
||||
<div className="w-1 h-8 bg-gradient-to-b from-[#d4af37] to-[#c9a227] rounded-full"></div>
|
||||
<h3 className="text-xl font-serif font-bold text-white">Filter by Tags</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedTag(null);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className={`group relative w-full text-left px-5 py-4 rounded-2xl text-sm font-medium transition-all duration-300 overflow-hidden ${
|
||||
selectedTag === null
|
||||
? 'bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] shadow-lg shadow-[#d4af37]/40'
|
||||
: 'bg-gradient-to-br from-[#0f0f0f] to-[#0a0a0a] text-gray-300 border-2 border-[#d4af37]/20 hover:border-[#d4af37]/50 hover:text-[#d4af37] hover:bg-[#1a1a1a]'
|
||||
}`}
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-2">
|
||||
<Tag className="w-4 h-4" />
|
||||
All Posts
|
||||
</span>
|
||||
{selectedTag === null && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-white/20 to-transparent animate-shimmer"></div>
|
||||
)}
|
||||
</button>
|
||||
{allTags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => {
|
||||
setSelectedTag(tag);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className={`group relative w-full text-left px-5 py-4 rounded-2xl text-sm font-medium transition-all duration-300 overflow-hidden ${
|
||||
selectedTag === tag
|
||||
? 'bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] shadow-lg shadow-[#d4af37]/40'
|
||||
: 'bg-gradient-to-br from-[#0f0f0f] to-[#0a0a0a] text-gray-300 border-2 border-[#d4af37]/20 hover:border-[#d4af37]/50 hover:text-[#d4af37] hover:bg-[#1a1a1a]'
|
||||
}`}
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-2">
|
||||
<Tag className="w-4 h-4" />
|
||||
{tag}
|
||||
</span>
|
||||
{selectedTag === tag && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-white/20 to-transparent animate-shimmer"></div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogPage;
|
||||
1187
Frontend/src/pages/admin/BlogManagementPage.tsx
Normal file
1187
Frontend/src/pages/admin/BlogManagementPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@ import authService from '../../services/api/authService';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import { Loading, EmptyState } from '../../components/common';
|
||||
import { useAsync } from '../../hooks/useAsync';
|
||||
import { useGlobalLoading } from '../../contexts/GlobalLoadingContext';
|
||||
import { useGlobalLoading } from '../../contexts/LoadingContext';
|
||||
import { normalizeImageUrl } from '../../utils/imageUtils';
|
||||
|
||||
const profileValidationSchema = yup.object().shape({
|
||||
|
||||
@@ -18,7 +18,8 @@ const ChatManagementPage: React.FC = () => {
|
||||
const [notificationWs, setNotificationWs] = useState<WebSocket | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const authStore = useAuthStore();
|
||||
const token = authStore.token || localStorage.getItem('token');
|
||||
// Tokens are in httpOnly cookies, not accessible from JavaScript
|
||||
// WebSocket authentication will use cookie-based auth
|
||||
const { refreshCount } = useChatNotifications();
|
||||
|
||||
const scrollToBottom = () => {
|
||||
@@ -57,13 +58,16 @@ const ChatManagementPage: React.FC = () => {
|
||||
}, [selectedChat]);
|
||||
|
||||
const connectNotificationWebSocket = () => {
|
||||
if (!token) return;
|
||||
// Check if user is authenticated (tokens are in httpOnly cookies)
|
||||
if (!authStore.isAuthenticated) return;
|
||||
|
||||
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
const normalizedBase = baseUrl.replace(/\/$/, '');
|
||||
const wsProtocol = normalizedBase.startsWith('https') ? 'wss' : 'ws';
|
||||
const wsBase = normalizedBase.replace(/^https?/, wsProtocol);
|
||||
const wsUrl = `${wsBase}/api/chat/ws/staff/notifications?token=${encodeURIComponent(token)}`;
|
||||
// Use cookie-based authentication - cookies are sent automatically with WebSocket
|
||||
// Backend should validate using cookies instead of query parameter
|
||||
const wsUrl = `${wsBase}/api/chat/ws/staff/notifications`;
|
||||
|
||||
const websocket = new WebSocket(wsUrl);
|
||||
|
||||
@@ -118,7 +122,8 @@ const ChatManagementPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const connectChatWebSocket = (chatId: number) => {
|
||||
if (!token) return;
|
||||
// Check if user is authenticated (tokens are in httpOnly cookies)
|
||||
if (!authStore.isAuthenticated) return;
|
||||
|
||||
if (ws) {
|
||||
ws.close();
|
||||
@@ -128,7 +133,9 @@ const ChatManagementPage: React.FC = () => {
|
||||
const normalizedBase = baseUrl.replace(/\/$/, '');
|
||||
const wsProtocol = normalizedBase.startsWith('https') ? 'wss' : 'ws';
|
||||
const wsBase = normalizedBase.replace(/^https?/, wsProtocol);
|
||||
const wsUrl = `${wsBase}/api/chat/ws/${chatId}?user_type=staff&token=${encodeURIComponent(token)}`;
|
||||
// Use cookie-based authentication - cookies are sent automatically with WebSocket
|
||||
// Backend should validate using cookies instead of query parameter
|
||||
const wsUrl = `${wsBase}/api/chat/ws/${chatId}?user_type=staff`;
|
||||
|
||||
const websocket = new WebSocket(wsUrl);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user