diff --git a/.gitignore b/.gitignore index fa65296..1e5aae1 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,8 @@ source-images/ # Auto Claude data directory .auto-claude/ +<<<<<<< HEAD +.auto-claude-* +.claude_settings.json +======= +>>>>>>> origin/master diff --git a/src/app/[locale]/blog/page.tsx b/src/app/[locale]/blog/page.tsx index fc351e3..5d475a4 100644 --- a/src/app/[locale]/blog/page.tsx +++ b/src/app/[locale]/blog/page.tsx @@ -1,17 +1,19 @@ import { setRequestLocale } from 'next-intl/server'; import { getTranslations } from 'next-intl/server'; -import { ArrowRight, Calendar, Tag, ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react'; -import Link from 'next/link'; -import Image from 'next/image'; +import { ArrowRight } from 'lucide-react'; import type { Metadata } from 'next'; import { getAllBlogPosts, BlogPost } from '@/lib/blog'; +<<<<<<< HEAD +import { BlogList } from '@/components/blog/BlogList'; +======= import { legacyBlogPosts } from '@/data/legacyBlogPosts'; const POSTS_PER_PAGE = 12; +>>>>>>> origin/master type Props = { params: Promise<{ locale: string }>; - searchParams: Promise<{ page?: string }>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }; const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://damjan-savic.com'; @@ -82,6 +84,8 @@ export async function generateMetadata({ params }: Props): Promise { }; } +<<<<<<< HEAD +======= function getFormattedDate(date: string, locale: string) { const languageMap: Record = { de: 'de-DE', @@ -334,10 +338,11 @@ function Pagination({ ); } +>>>>>>> origin/master export default async function BlogPage({ params, searchParams }: Props) { const { locale } = await params; - const { page } = await searchParams; + const resolvedSearchParams = await searchParams; setRequestLocale(locale); const t = await getTranslations('blog'); @@ -355,14 +360,9 @@ export default async function BlogPage({ params, searchParams }: Props) { ...legacyPosts, ].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); - // Pagination - const currentPage = Math.max(1, parseInt(page || '1', 10) || 1); - const totalPages = Math.ceil(allPosts.length / POSTS_PER_PAGE); - const validPage = Math.min(currentPage, totalPages || 1); - - const startIndex = (validPage - 1) * POSTS_PER_PAGE; - const endIndex = startIndex + POSTS_PER_PAGE; - const posts = allPosts.slice(startIndex, endIndex); + // Extract search and category from URL params + const initialSearch = typeof resolvedSearchParams.search === 'string' ? resolvedSearchParams.search : ''; + const initialCategory = typeof resolvedSearchParams.category === 'string' ? resolvedSearchParams.category : null; return (
@@ -379,37 +379,23 @@ export default async function BlogPage({ params, searchParams }: Props) {

{t('meta.header.subtitle')}

- {/* Post count */} -

- {t('ui.pagination.showing', { - start: startIndex + 1, - end: Math.min(endIndex, allPosts.length), - total: allPosts.length, - })} -

- {/* Blog Posts Grid */} -
- {posts.map((post) => ( - - ))} -
- - {/* Pagination */} - t(key)} + initialSearch={initialSearch} + initialCategory={initialCategory} + translations={{ + searchPlaceholder: t('ui.search.placeholder'), + showingText: (start: number, end: number, total: number) => + t('ui.pagination.showing', { start, end, total }), + noPostsText: t('ui.errors.posts'), + paginationPrevious: t('ui.pagination.previous'), + paginationNext: t('ui.pagination.next'), + }} /> - - {/* Empty State */} - {posts.length === 0 && ( -
-

{t('ui.errors.posts')}

-
- )}
); diff --git a/src/components/blog/BlogList.tsx b/src/components/blog/BlogList.tsx new file mode 100644 index 0000000..40eedd6 --- /dev/null +++ b/src/components/blog/BlogList.tsx @@ -0,0 +1,427 @@ +'use client'; + +import { useState, useMemo, useEffect } from 'react'; +import { Calendar, Tag, ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react'; +import Link from 'next/link'; +import Image from 'next/image'; +import { useRouter, usePathname } from 'next/navigation'; +import { BlogPost } from '@/lib/blog'; +import { SearchBar } from './SearchBar'; +import { CategoryFilter } from './CategoryFilter'; + +const POSTS_PER_PAGE = 12; + +// Placeholder image for posts without cover images +const PLACEHOLDER_IMAGE = 'data:image/svg+xml,' + encodeURIComponent(` + + + + + + + +`); + +// Known existing images (from public/images/posts/) +const EXISTING_IMAGES = new Set([ + '/images/posts/erp-integration-breuninger/cover.jpg', + '/images/posts/fullstack-development-timetracking/cover.jpg', + '/images/posts/rfid-automation/cover.jpg', + '/images/posts/automated-ad-creatives/cover.jpg', +]); + +function getFormattedDate(date: string, locale: string) { + const languageMap: Record = { + de: 'de-DE', + en: 'en-US', + sr: 'sr-RS', + }; + + return new Date(date).toLocaleDateString(languageMap[locale] || 'de-DE', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); +} + +function getImagePath(coverImage: string) { + if (coverImage.startsWith('http')) { + return coverImage; + } + return coverImage.replace('/blog/', '/images/posts/'); +} + +// Blog image component with fallback +function BlogImage({ src, alt, fallback }: { src: string; alt: string; fallback: string }) { + const imageSrc = EXISTING_IMAGES.has(src) ? src : fallback; + + return ( + {alt} + ); +} + +function BlogPostCard({ post, locale }: { post: BlogPost; locale: string }) { + return ( + +
+ {/* Image Container */} +
+ +
+
+ + {/* Content */} +
+
+
+ + {getFormattedDate(post.date, locale)} +
+ {post.tags && post.tags.length > 0 && ( +
+ + {post.tags.length} Tags +
+ )} +
+ +

+ {post.title} +

+

+ {post.excerpt} +

+ + {/* Tags */} +
+ {post.tags?.slice(0, 3).map((tag, index) => ( + + {tag} + + ))} + {post.tags && post.tags.length > 3 && ( + + +{post.tags.length - 3} more + + )} +
+ + {/* Link Icon */} +
+ +
+
+
+ + ); +} + +// Pagination component +function Pagination({ + currentPage, + totalPages, + onPageChange, + t, +}: { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + t: (key: string) => string; +}) { + const getPageNumbers = () => { + const pages: (number | 'ellipsis')[] = []; + + if (totalPages <= 7) { + // Show all pages if 7 or fewer + for (let i = 1; i <= totalPages; i++) { + pages.push(i); + } + } else { + // Always show first page + pages.push(1); + + if (currentPage > 3) { + pages.push('ellipsis'); + } + + // Show pages around current + const start = Math.max(2, currentPage - 1); + const end = Math.min(totalPages - 1, currentPage + 1); + + for (let i = start; i <= end; i++) { + pages.push(i); + } + + if (currentPage < totalPages - 2) { + pages.push('ellipsis'); + } + + // Always show last page + pages.push(totalPages); + } + + return pages; + }; + + if (totalPages <= 1) return null; + + return ( + + ); +} + +interface BlogListProps { + posts: BlogPost[]; + locale: string; + initialSearch?: string; + initialCategory?: string | null; + translations: { + searchPlaceholder: string; + showingText: (start: number, end: number, total: number) => string; + noPostsText: string; + paginationPrevious: string; + paginationNext: string; + }; +} + +export function BlogList({ posts, locale, initialSearch = '', initialCategory = null, translations }: BlogListProps) { + const router = useRouter(); + const pathname = usePathname(); + + const [searchQuery, setSearchQuery] = useState(initialSearch); + const [selectedCategory, setSelectedCategory] = useState(initialCategory); + const [currentPage, setCurrentPage] = useState(1); + + // Update URL when filters change + useEffect(() => { + const params = new URLSearchParams(); + + if (searchQuery.trim()) { + params.set('search', searchQuery.trim()); + } + + if (selectedCategory) { + params.set('category', selectedCategory); + } + + const queryString = params.toString(); + const newUrl = queryString ? `${pathname}?${queryString}` : pathname; + + router.push(newUrl, { scroll: false }); + }, [searchQuery, selectedCategory, pathname, router]); + + // Extract unique categories from all posts + const allCategories = useMemo(() => { + const categories = new Set(posts.map(post => post.category).filter(Boolean)); + return Array.from(categories).sort(); + }, [posts]); + + // Filter posts based on search query and category + const filteredPosts = useMemo(() => { + let filtered = posts; + + // Apply search filter (case-insensitive search in title, excerpt, and tags) + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + filtered = filtered.filter(post => { + const titleMatch = post.title.toLowerCase().includes(query); + const excerptMatch = post.excerpt.toLowerCase().includes(query); + const tagsMatch = post.tags?.some(tag => tag.toLowerCase().includes(query)) || false; + return titleMatch || excerptMatch || tagsMatch; + }); + } + + // Apply category filter + if (selectedCategory) { + filtered = filtered.filter(post => post.category === selectedCategory); + } + + return filtered; + }, [posts, searchQuery, selectedCategory]); + + // Reset to first page when filters change + useMemo(() => { + setCurrentPage(1); + }, [searchQuery, selectedCategory]); + + // Paginate filtered posts + const totalPages = Math.ceil(filteredPosts.length / POSTS_PER_PAGE); + const validPage = Math.min(Math.max(1, currentPage), totalPages || 1); + + const startIndex = (validPage - 1) * POSTS_PER_PAGE; + const endIndex = startIndex + POSTS_PER_PAGE; + const paginatedPosts = filteredPosts.slice(startIndex, endIndex); + + const handlePageChange = (page: number) => { + setCurrentPage(page); + // Scroll to top of the page + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + return ( +
+ {/* Search and Filter Section */} +
+ {/* Search Bar */} + + + {/* Category Filter */} + +
+ + {/* Results count */} +

+ {translations.showingText( + startIndex + 1, + Math.min(endIndex, filteredPosts.length), + filteredPosts.length + )} +

+ + {/* Blog Posts Grid */} + {paginatedPosts.length > 0 ? ( + <> +
+ {paginatedPosts.map((post) => ( + + ))} +
+ + {/* Pagination */} + { + if (key === 'ui.pagination.previous') return translations.paginationPrevious; + if (key === 'ui.pagination.next') return translations.paginationNext; + return ''; + }} + /> + + ) : ( + /* Empty State */ +
+

{translations.noPostsText}

+
+ )} +
+ ); +} diff --git a/src/components/blog/CategoryFilter.tsx b/src/components/blog/CategoryFilter.tsx new file mode 100644 index 0000000..8d479b7 --- /dev/null +++ b/src/components/blog/CategoryFilter.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { Tag } from 'lucide-react'; +import { motion } from 'framer-motion'; + +interface CategoryFilterProps { + categories: string[]; + selectedCategory: string | null; + onCategoryChange: (category: string | null) => void; + className?: string; +} + +export function CategoryFilter({ + categories, + selectedCategory, + onCategoryChange, + className = '' +}: CategoryFilterProps) { + const allCategories = ['All', ...categories]; + + return ( + +
+ +

Filter by Category

+
+ + {/* Mobile: Horizontal scrollable */} +
+
+ {allCategories.map((category) => { + const isActive = category === 'All' + ? selectedCategory === null + : selectedCategory === category; + + return ( + + ); + })} +
+
+ + {/* Desktop: Grid */} +
+ {allCategories.map((category) => { + const isActive = category === 'All' + ? selectedCategory === null + : selectedCategory === category; + + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/blog/SearchBar.tsx b/src/components/blog/SearchBar.tsx new file mode 100644 index 0000000..4bc3520 --- /dev/null +++ b/src/components/blog/SearchBar.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { Search, X } from 'lucide-react'; +import { motion } from 'framer-motion'; + +interface SearchBarProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; +} + +export function SearchBar({ + value, + onChange, + placeholder = 'Search posts...', + className = '' +}: SearchBarProps) { + const handleClear = () => { + onChange(''); + }; + + return ( + +
+ + onChange(e.target.value)} + placeholder={placeholder} + className="w-full pl-12 pr-12 py-3 bg-zinc-900/50 border border-zinc-800 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-[#697565] focus:border-transparent transition-all" + /> + {value && ( + + )} +
+
+ ); +} diff --git a/src/messages/de.json b/src/messages/de.json index ee85cad..66dac77 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -477,12 +477,15 @@ }, "ui": { "readMore": "Weiterlesen", + "search": { + "placeholder": "Beiträge durchsuchen..." + }, "loading": { "posts": "Blogbeiträge werden geladen...", "post": "Blogbeitrag wird geladen..." }, "errors": { - "posts": "Fehler beim Laden der Blogbeiträge", + "posts": "Keine Beiträge gefunden", "post": "Der gesuchte Blogbeitrag existiert nicht oder wurde verschoben." }, "backToBlog": "Zurück zum Blog", diff --git a/src/messages/en.json b/src/messages/en.json index 5b26f07..53a9625 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -492,12 +492,15 @@ }, "ui": { "readMore": "Read More", + "search": { + "placeholder": "Search posts..." + }, "loading": { "posts": "Loading blog posts...", "post": "Loading blog post..." }, "errors": { - "posts": "Error loading blog posts", + "posts": "No posts found", "post": "The requested blog post does not exist or has been moved." }, "backToBlog": "Back to Blog", diff --git a/src/messages/sr.json b/src/messages/sr.json index d229ad9..0297877 100644 --- a/src/messages/sr.json +++ b/src/messages/sr.json @@ -499,12 +499,15 @@ }, "ui": { "readMore": "Procitaj vise", + "search": { + "placeholder": "Pretrazi postove..." + }, "loading": { "posts": "Ucitavanje blog postova...", "post": "Ucitavanje blog posta..." }, "errors": { - "posts": "Greska pri ucitavanju blog postova", + "posts": "Nema pronađenih postova", "post": "Trazeni blog post ne postoji ili je premesten." }, "backToBlog": "Nazad na Blog",