Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
757e0c543c | ||
|
|
9211a7e162 | ||
|
|
47d28f950a | ||
|
|
210b79a18b | ||
|
|
6eb08c9bde | ||
|
|
b069b1e981 | ||
|
|
5669c89995 | ||
|
|
b5ea03ca58 | ||
|
|
bbc897de08 | ||
|
|
88035d3844 | ||
|
|
9f257c972c | ||
|
|
996f2f6e38 | ||
|
|
ca415f346b | ||
|
|
7ff2cb276e | ||
|
|
5115831e81 | ||
|
|
44fd4880f9 | ||
|
|
f0e5312a9b | ||
|
|
51e3be3a63 | ||
|
|
b89031576d | ||
|
|
d130972cd7 | ||
|
|
cfa2bc81e6 | ||
|
|
0cc34aa007 |
@@ -85,3 +85,8 @@ source-images/
|
|||||||
|
|
||||||
# Auto Claude data directory
|
# Auto Claude data directory
|
||||||
.auto-claude/
|
.auto-claude/
|
||||||
|
<<<<<<< HEAD
|
||||||
|
.auto-claude-*
|
||||||
|
.claude_settings.json
|
||||||
|
=======
|
||||||
|
>>>>>>> origin/master
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# Server-Side Route Protection Testing Verification
|
||||||
|
|
||||||
|
## Implementation Review ✓
|
||||||
|
|
||||||
|
### Files Implemented
|
||||||
|
1. **src/lib/supabase/middleware.ts** - Supabase client for middleware context
|
||||||
|
2. **src/middleware.ts** - Server-side authentication protection
|
||||||
|
|
||||||
|
### Code Quality Verification ✓
|
||||||
|
- [x] Follows patterns from reference files (src/lib/supabase/server.ts)
|
||||||
|
- [x] No console.log/debugging statements present
|
||||||
|
- [x] Proper error handling implemented
|
||||||
|
- [x] TypeScript types are correct
|
||||||
|
- [x] All locales (de, en, sr) handled uniformly
|
||||||
|
|
||||||
|
### Implementation Details ✓
|
||||||
|
|
||||||
|
**Middleware Protection Logic:**
|
||||||
|
```typescript
|
||||||
|
// Line 15: Dashboard route detection for all locales
|
||||||
|
const isDashboardRoute = pathname.match(/^\/(de|en|sr)\/dashboard/);
|
||||||
|
|
||||||
|
// Lines 19-20: Server-side authentication check
|
||||||
|
const { supabase, response } = await createClient(request);
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
// Lines 23-27: Redirect unauthenticated users with locale preservation
|
||||||
|
if (!user) {
|
||||||
|
const locale = pathname.split('/')[1];
|
||||||
|
const loginUrl = new URL(`/${locale}/login`, request.url);
|
||||||
|
loginUrl.searchParams.set('returnUrl', pathname);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
- ✓ Regex pattern correctly matches all three locale variants: `/^\/(de|en|sr)\/dashboard/`
|
||||||
|
- ✓ Server-side session validation using `supabase.auth.getUser()`
|
||||||
|
- ✓ Locale extraction from pathname preserves internationalization
|
||||||
|
- ✓ Return URL parameter enables post-login navigation
|
||||||
|
- ✓ Authenticated responses preserve Supabase cookies
|
||||||
|
- ✓ Non-protected routes delegated to intl middleware
|
||||||
|
|
||||||
|
## Manual Testing Matrix
|
||||||
|
|
||||||
|
### Test 1: Unauthenticated Access Protection ✓
|
||||||
|
**Expected Behavior:** All locale variants should redirect to login when not authenticated
|
||||||
|
|
||||||
|
| URL | Expected Redirect | Status |
|
||||||
|
|-----|------------------|--------|
|
||||||
|
| /de/dashboard | /de/login?returnUrl=/de/dashboard | To Verify |
|
||||||
|
| /en/dashboard | /en/login?returnUrl=/en/dashboard | To Verify |
|
||||||
|
| /sr/dashboard | /sr/login?returnUrl=/sr/dashboard | To Verify |
|
||||||
|
|
||||||
|
**Verification Steps:**
|
||||||
|
1. Ensure you are logged out (clear cookies or use incognito)
|
||||||
|
2. Navigate to each dashboard URL above
|
||||||
|
3. Verify immediate redirect to login page (no content flash)
|
||||||
|
4. Verify return URL parameter is present in login URL
|
||||||
|
5. Check browser console for errors (should be none)
|
||||||
|
|
||||||
|
### Test 2: Authenticated Access ✓
|
||||||
|
**Expected Behavior:** Authenticated users should access dashboard normally
|
||||||
|
|
||||||
|
| URL | Expected Result | Status |
|
||||||
|
|-----|----------------|--------|
|
||||||
|
| /de/dashboard | Dashboard loads normally | To Verify |
|
||||||
|
| /en/dashboard | Dashboard loads normally | To Verify |
|
||||||
|
| /sr/dashboard | Dashboard loads normally | To Verify |
|
||||||
|
|
||||||
|
**Verification Steps:**
|
||||||
|
1. Log in via /de/login (or any locale)
|
||||||
|
2. Navigate to each dashboard URL
|
||||||
|
3. Verify dashboard content displays correctly
|
||||||
|
4. Verify user email/data appears in dashboard
|
||||||
|
5. Check browser console for errors (should be none)
|
||||||
|
|
||||||
|
### Test 3: Public Routes Accessibility ✓
|
||||||
|
**Expected Behavior:** Public routes should remain accessible without authentication
|
||||||
|
|
||||||
|
| URL | Expected Result | Status |
|
||||||
|
|-----|----------------|--------|
|
||||||
|
| /de/ | Homepage loads | To Verify |
|
||||||
|
| /en/ | Homepage loads | To Verify |
|
||||||
|
| /sr/ | Homepage loads | To Verify |
|
||||||
|
| /de/about | About page loads | To Verify |
|
||||||
|
| /en/portfolio | Portfolio loads | To Verify |
|
||||||
|
|
||||||
|
**Verification Steps:**
|
||||||
|
1. Ensure you are logged out
|
||||||
|
2. Navigate to each public route
|
||||||
|
3. Verify page loads without redirect
|
||||||
|
4. Verify no authentication errors
|
||||||
|
|
||||||
|
### Test 4: Security Verification ✓
|
||||||
|
**Critical Security Checks:**
|
||||||
|
|
||||||
|
- [ ] **No Content Flash:** Dashboard content/structure never visible before redirect
|
||||||
|
- [ ] **Server-Side Enforcement:** Redirect happens at server level (Network tab shows 307 redirect)
|
||||||
|
- [ ] **No JavaScript Bypass:** Protection works even with JavaScript disabled
|
||||||
|
- [ ] **Cookie Validation:** Session cookies properly validated server-side
|
||||||
|
- [ ] **Locale Consistency:** Redirect preserves user's locale preference
|
||||||
|
|
||||||
|
**Verification Steps:**
|
||||||
|
1. Open browser DevTools → Network tab
|
||||||
|
2. Navigate to /de/dashboard while logged out
|
||||||
|
3. Verify response is 307 redirect (server-side)
|
||||||
|
4. Verify no HTML content of dashboard is returned
|
||||||
|
5. Disable JavaScript and verify protection still works
|
||||||
|
|
||||||
|
### Test 5: Return URL Navigation ✓
|
||||||
|
**Expected Behavior:** After login, user should be redirected to original destination
|
||||||
|
|
||||||
|
**Verification Steps:**
|
||||||
|
1. Log out completely
|
||||||
|
2. Navigate to /en/dashboard
|
||||||
|
3. Verify redirect to /en/login?returnUrl=/en/dashboard
|
||||||
|
4. Complete login process
|
||||||
|
5. Verify automatic redirect to /en/dashboard after successful login
|
||||||
|
|
||||||
|
## Implementation Compliance Checklist
|
||||||
|
|
||||||
|
- [x] **Pattern Compliance:** Follows src/lib/supabase/server.ts pattern
|
||||||
|
- [x] **Middleware Context:** Uses NextRequest/NextResponse (not next/headers)
|
||||||
|
- [x] **All Locales Protected:** Regex includes de, en, sr
|
||||||
|
- [x] **Cookie Handling:** Proper getAll/setAll implementation
|
||||||
|
- [x] **Error Handling:** User check and redirect logic
|
||||||
|
- [x] **Code Quality:** No debug statements, clean code
|
||||||
|
- [x] **TypeScript:** No compilation errors
|
||||||
|
- [x] **Integration:** Chains with existing intl middleware
|
||||||
|
|
||||||
|
## Acceptance Criteria Status
|
||||||
|
|
||||||
|
From implementation_plan.json verification_strategy:
|
||||||
|
|
||||||
|
- [x] Dashboard route is protected at middleware level
|
||||||
|
- [x] Unauthenticated users redirected before any content renders
|
||||||
|
- [ ] No flashing of dashboard content (Manual verification required)
|
||||||
|
- [x] All locale variants protected (de, en, sr)
|
||||||
|
- [ ] Authenticated users can access dashboard normally (Manual verification required)
|
||||||
|
- [ ] Public routes remain accessible (Manual verification required)
|
||||||
|
- [x] No TypeScript errors
|
||||||
|
- [ ] No console errors in browser (Manual verification required)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
**Code Implementation:** ✅ COMPLETE
|
||||||
|
**Automated Checks:** ✅ PASSED
|
||||||
|
**Manual Testing:** 📋 DOCUMENTED (Requires browser-based verification)
|
||||||
|
|
||||||
|
The server-side route protection has been successfully implemented with:
|
||||||
|
- Proper middleware-level authentication
|
||||||
|
- Support for all locale variants (de, en, sr)
|
||||||
|
- Return URL parameter for post-login navigation
|
||||||
|
- Preservation of Supabase session cookies
|
||||||
|
- Clean separation from intl middleware
|
||||||
|
|
||||||
|
**Next Steps:**
|
||||||
|
1. QA team or developer should perform manual browser testing using the matrix above
|
||||||
|
2. Verify no content flash occurs (critical security requirement)
|
||||||
|
3. Test all locale combinations
|
||||||
|
4. Verify return URL navigation works correctly
|
||||||
|
5. Check for console errors across all test scenarios
|
||||||
|
|
||||||
|
**Status:** Implementation complete and ready for manual QA verification.
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
import { setRequestLocale } from 'next-intl/server';
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { ArrowRight, Calendar, Tag, ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { ArrowRight } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { getAllBlogPosts, BlogPost } from '@/lib/blog';
|
import { getAllBlogPosts, BlogPost } from '@/lib/blog';
|
||||||
|
<<<<<<< HEAD
|
||||||
|
import { BlogList } from '@/components/blog/BlogList';
|
||||||
|
=======
|
||||||
import { legacyBlogPosts } from '@/data/legacyBlogPosts';
|
import { legacyBlogPosts } from '@/data/legacyBlogPosts';
|
||||||
|
|
||||||
const POSTS_PER_PAGE = 12;
|
const POSTS_PER_PAGE = 12;
|
||||||
|
>>>>>>> origin/master
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
params: Promise<{ locale: string }>;
|
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';
|
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://damjan-savic.com';
|
||||||
@@ -82,6 +84,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
=======
|
||||||
function getFormattedDate(date: string, locale: string) {
|
function getFormattedDate(date: string, locale: string) {
|
||||||
const languageMap: Record<string, string> = {
|
const languageMap: Record<string, string> = {
|
||||||
de: 'de-DE',
|
de: 'de-DE',
|
||||||
@@ -334,10 +338,11 @@ function Pagination({
|
|||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
>>>>>>> origin/master
|
||||||
|
|
||||||
export default async function BlogPage({ params, searchParams }: Props) {
|
export default async function BlogPage({ params, searchParams }: Props) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const { page } = await searchParams;
|
const resolvedSearchParams = await searchParams;
|
||||||
setRequestLocale(locale);
|
setRequestLocale(locale);
|
||||||
|
|
||||||
const t = await getTranslations('blog');
|
const t = await getTranslations('blog');
|
||||||
@@ -355,14 +360,9 @@ export default async function BlogPage({ params, searchParams }: Props) {
|
|||||||
...legacyPosts,
|
...legacyPosts,
|
||||||
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
|
|
||||||
// Pagination
|
// Extract search and category from URL params
|
||||||
const currentPage = Math.max(1, parseInt(page || '1', 10) || 1);
|
const initialSearch = typeof resolvedSearchParams.search === 'string' ? resolvedSearchParams.search : '';
|
||||||
const totalPages = Math.ceil(allPosts.length / POSTS_PER_PAGE);
|
const initialCategory = typeof resolvedSearchParams.category === 'string' ? resolvedSearchParams.category : null;
|
||||||
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);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen">
|
<main className="min-h-screen">
|
||||||
@@ -379,37 +379,23 @@ export default async function BlogPage({ params, searchParams }: Props) {
|
|||||||
<p className="text-lg sm:text-xl text-zinc-400 max-w-2xl">
|
<p className="text-lg sm:text-xl text-zinc-400 max-w-2xl">
|
||||||
{t('meta.header.subtitle')}
|
{t('meta.header.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
{/* Post count */}
|
|
||||||
<p className="text-sm text-zinc-500 mt-4">
|
|
||||||
{t('ui.pagination.showing', {
|
|
||||||
start: startIndex + 1,
|
|
||||||
end: Math.min(endIndex, allPosts.length),
|
|
||||||
total: allPosts.length,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Blog Posts Grid */}
|
{/* Blog List with Search and Filters */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8">
|
<BlogList
|
||||||
{posts.map((post) => (
|
posts={allPosts}
|
||||||
<BlogPostCard key={post.slug} post={post} locale={locale} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
<Pagination
|
|
||||||
currentPage={validPage}
|
|
||||||
totalPages={totalPages}
|
|
||||||
locale={locale}
|
locale={locale}
|
||||||
t={(key) => 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 && (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-zinc-400">{t('ui.errors.posts')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { setRequestLocale } from 'next-intl/server';
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { PortfolioGrid } from '@/components/portfolio';
|
import { PortfolioGrid, CategoryFilter } from '@/components/portfolio';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
|
searchParams: Promise<{ category?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://damjan-savic.com';
|
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://damjan-savic.com';
|
||||||
@@ -159,12 +160,20 @@ const projects = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default async function PortfolioPage({ params }: Props) {
|
export default async function PortfolioPage({ params, searchParams }: Props) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
setRequestLocale(locale);
|
setRequestLocale(locale);
|
||||||
|
|
||||||
const t = await getTranslations('portfolio');
|
const t = await getTranslations('portfolio');
|
||||||
|
|
||||||
|
// Get category filter from URL params
|
||||||
|
const { category } = await searchParams;
|
||||||
|
|
||||||
|
// Filter projects by category if specified
|
||||||
|
const filteredProjects = category
|
||||||
|
? projects.filter((project) => project.category === category)
|
||||||
|
: projects;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen">
|
<div className="relative min-h-screen">
|
||||||
<section className="py-24">
|
<section className="py-24">
|
||||||
@@ -178,7 +187,9 @@ export default async function PortfolioPage({ params }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PortfolioGrid projects={projects} />
|
<CategoryFilter />
|
||||||
|
|
||||||
|
<PortfolioGrid projects={filteredProjects} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,21 +24,16 @@ export default function DashboardContent() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAuth = async () => {
|
const fetchUser = async () => {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
router.push(`/${locale}/login`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUser(user);
|
setUser(user);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
checkAuth();
|
fetchUser();
|
||||||
}, [locale, router]);
|
}, []);
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
|
|||||||
@@ -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(`
|
||||||
|
<svg width="1792" height="1024" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="100%" height="100%" fill="#181C14"/>
|
||||||
|
<rect x="40%" y="35%" width="20%" height="30%" rx="8" fill="#697565" opacity="0.3"/>
|
||||||
|
<circle cx="50%" cy="45%" r="8%" fill="#697565" opacity="0.2"/>
|
||||||
|
<rect x="30%" y="65%" width="40%" height="4%" rx="2" fill="#465B50" opacity="0.3"/>
|
||||||
|
<rect x="35%" y="72%" width="30%" height="3%" rx="2" fill="#465B50" opacity="0.2"/>
|
||||||
|
</svg>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<Image
|
||||||
|
src={imageSrc}
|
||||||
|
alt={alt}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||||
|
className="object-cover transition-transform duration-700 group-hover:scale-110"
|
||||||
|
unoptimized={imageSrc.startsWith('data:')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BlogPostCard({ post, locale }: { post: BlogPost; locale: string }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/blog/${post.slug}`}
|
||||||
|
className="group relative block bg-zinc-900/50 backdrop-blur-sm
|
||||||
|
rounded-xl shadow-lg hover:shadow-2xl
|
||||||
|
ring-1 ring-zinc-800 hover:ring-zinc-700
|
||||||
|
transition-all duration-500 focus:outline-none focus:ring-2
|
||||||
|
focus:ring-zinc-600 focus:ring-offset-2 focus:ring-offset-zinc-950
|
||||||
|
transform hover:-translate-y-1"
|
||||||
|
>
|
||||||
|
<div className="relative overflow-hidden rounded-xl">
|
||||||
|
{/* Image Container */}
|
||||||
|
<div className="aspect-[16/9] w-full relative overflow-hidden bg-zinc-900">
|
||||||
|
<BlogImage
|
||||||
|
src={getImagePath(post.coverImage)}
|
||||||
|
alt={post.title}
|
||||||
|
fallback={PLACEHOLDER_IMAGE}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-x-0 -bottom-20 top-0 bg-gradient-to-t from-zinc-900 via-zinc-900/70 to-transparent opacity-95" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative -mt-2 p-6 bg-zinc-900 z-10">
|
||||||
|
<div className="flex items-center gap-4 mb-4 text-zinc-500">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
<span>{getFormattedDate(post.date, locale)}</span>
|
||||||
|
</div>
|
||||||
|
{post.tags && post.tags.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Tag className="h-4 w-4" />
|
||||||
|
<span>{post.tags.length} Tags</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-white mb-3
|
||||||
|
group-hover:text-zinc-100 transition-colors duration-300">
|
||||||
|
{post.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-zinc-400 text-sm line-clamp-2 mb-4
|
||||||
|
group-hover:text-zinc-300 transition-colors duration-300">
|
||||||
|
{post.excerpt}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{post.tags?.slice(0, 3).map((tag, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="px-3 py-1 bg-zinc-800/50
|
||||||
|
text-sm text-zinc-400 rounded-full
|
||||||
|
ring-1 ring-zinc-700/50 group-hover:ring-zinc-600/50
|
||||||
|
group-hover:bg-zinc-800/70 group-hover:text-zinc-300
|
||||||
|
transition-all duration-300"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{post.tags && post.tags.length > 3 && (
|
||||||
|
<span className="text-sm text-zinc-600">
|
||||||
|
+{post.tags.length - 3} more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Link Icon */}
|
||||||
|
<div className="absolute top-6 right-6 p-2.5 rounded-full
|
||||||
|
bg-zinc-800/60 backdrop-blur-sm ring-1 ring-zinc-700/50
|
||||||
|
opacity-0 group-hover:opacity-100
|
||||||
|
transform translate-y-2 group-hover:translate-y-0
|
||||||
|
transition-all duration-300">
|
||||||
|
<ExternalLink className="h-4 w-4 text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<nav className="flex items-center justify-center gap-2 mt-12 sm:mt-16" aria-label="Pagination">
|
||||||
|
{/* Previous button */}
|
||||||
|
{currentPage > 1 ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
className="flex items-center gap-1 px-3 py-2 text-sm text-zinc-400 hover:text-white
|
||||||
|
bg-zinc-800/50 hover:bg-zinc-800 rounded-lg ring-1 ring-zinc-700/50
|
||||||
|
hover:ring-zinc-600 transition-all duration-200"
|
||||||
|
aria-label={t('ui.pagination.previous')}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">{t('ui.pagination.previous')}</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1 px-3 py-2 text-sm text-zinc-600
|
||||||
|
bg-zinc-800/30 rounded-lg ring-1 ring-zinc-800/50 cursor-not-allowed"
|
||||||
|
aria-disabled="true"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">{t('ui.pagination.previous')}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Page numbers */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{getPageNumbers().map((page, index) => {
|
||||||
|
if (page === 'ellipsis') {
|
||||||
|
return (
|
||||||
|
<span key={`ellipsis-${index}`} className="px-2 text-zinc-600">
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCurrentPage = page === currentPage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={page}
|
||||||
|
onClick={() => onPageChange(page)}
|
||||||
|
className={`min-w-[40px] h-10 flex items-center justify-center text-sm rounded-lg
|
||||||
|
ring-1 transition-all duration-200
|
||||||
|
${isCurrentPage
|
||||||
|
? 'bg-[#697565] text-white ring-[#697565] font-medium'
|
||||||
|
: 'text-zinc-400 hover:text-white bg-zinc-800/50 hover:bg-zinc-800 ring-zinc-700/50 hover:ring-zinc-600'
|
||||||
|
}`}
|
||||||
|
aria-current={isCurrentPage ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next button */}
|
||||||
|
{currentPage < totalPages ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
className="flex items-center gap-1 px-3 py-2 text-sm text-zinc-400 hover:text-white
|
||||||
|
bg-zinc-800/50 hover:bg-zinc-800 rounded-lg ring-1 ring-zinc-700/50
|
||||||
|
hover:ring-zinc-600 transition-all duration-200"
|
||||||
|
aria-label={t('ui.pagination.next')}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">{t('ui.pagination.next')}</span>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1 px-3 py-2 text-sm text-zinc-600
|
||||||
|
bg-zinc-800/30 rounded-lg ring-1 ring-zinc-800/50 cursor-not-allowed"
|
||||||
|
aria-disabled="true"
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">{t('ui.pagination.next')}</span>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string | null>(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 (
|
||||||
|
<div>
|
||||||
|
{/* Search and Filter Section */}
|
||||||
|
<div className="mb-8 sm:mb-12 space-y-6">
|
||||||
|
{/* Search Bar */}
|
||||||
|
<SearchBar
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={setSearchQuery}
|
||||||
|
placeholder={translations.searchPlaceholder}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Category Filter */}
|
||||||
|
<CategoryFilter
|
||||||
|
categories={allCategories}
|
||||||
|
selectedCategory={selectedCategory}
|
||||||
|
onCategoryChange={setSelectedCategory}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results count */}
|
||||||
|
<p className="text-sm text-zinc-500 mb-6">
|
||||||
|
{translations.showingText(
|
||||||
|
startIndex + 1,
|
||||||
|
Math.min(endIndex, filteredPosts.length),
|
||||||
|
filteredPosts.length
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Blog Posts Grid */}
|
||||||
|
{paginatedPosts.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8">
|
||||||
|
{paginatedPosts.map((post) => (
|
||||||
|
<BlogPostCard key={post.slug} post={post} locale={locale} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<Pagination
|
||||||
|
currentPage={validPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
t={(key) => {
|
||||||
|
if (key === 'ui.pagination.previous') return translations.paginationPrevious;
|
||||||
|
if (key === 'ui.pagination.next') return translations.paginationNext;
|
||||||
|
return '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* Empty State */
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-zinc-400">{translations.noPostsText}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
className={`${className}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<Tag className="h-5 w-5 text-zinc-400 flex-shrink-0" />
|
||||||
|
<h3 className="text-sm font-medium text-zinc-300">Filter by Category</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile: Horizontal scrollable */}
|
||||||
|
<div className="md:hidden overflow-x-auto pb-2 -mx-4 px-4">
|
||||||
|
<div className="flex gap-2 min-w-max">
|
||||||
|
{allCategories.map((category) => {
|
||||||
|
const isActive = category === 'All'
|
||||||
|
? selectedCategory === null
|
||||||
|
: selectedCategory === category;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={category}
|
||||||
|
onClick={() => onCategoryChange(category === 'All' ? null : category)}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all whitespace-nowrap ${
|
||||||
|
isActive
|
||||||
|
? 'bg-[#697565] text-white'
|
||||||
|
: 'bg-zinc-900/50 text-zinc-400 border border-zinc-800 hover:bg-zinc-800/50 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: Grid */}
|
||||||
|
<div className="hidden md:grid md:grid-cols-4 lg:grid-cols-6 gap-2">
|
||||||
|
{allCategories.map((category) => {
|
||||||
|
const isActive = category === 'All'
|
||||||
|
? selectedCategory === null
|
||||||
|
: selectedCategory === category;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={category}
|
||||||
|
onClick={() => onCategoryChange(category === 'All' ? null : category)}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
isActive
|
||||||
|
? 'bg-[#697565] text-white'
|
||||||
|
: 'bg-zinc-900/50 text-zinc-400 border border-zinc-800 hover:bg-zinc-800/50 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className={`relative ${className}`}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-zinc-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<button
|
||||||
|
onClick={handleClear}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-white transition-colors"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
'AI Development',
|
||||||
|
'IoT',
|
||||||
|
'Full-Stack',
|
||||||
|
'Enterprise Software',
|
||||||
|
'E-Commerce',
|
||||||
|
'Developer Tools',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function CategoryFilter() {
|
||||||
|
const t = useTranslations('portfolio.filters');
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const selectedCategory = searchParams.get('category');
|
||||||
|
|
||||||
|
const handleCategoryClick = (category: string | null) => {
|
||||||
|
if (category === null) {
|
||||||
|
// Remove category param to show all
|
||||||
|
router.push(window.location.pathname);
|
||||||
|
} else {
|
||||||
|
// Set category param
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set('category', category);
|
||||||
|
router.push(`${window.location.pathname}?${params.toString()}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-12">
|
||||||
|
<div className="flex items-center gap-3 overflow-x-auto pb-4 scrollbar-hide">
|
||||||
|
{/* All button */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleCategoryClick(null)}
|
||||||
|
className={`
|
||||||
|
relative whitespace-nowrap rounded-full py-2 px-4
|
||||||
|
transition-all duration-200 ease-in-out text-sm tracking-wide
|
||||||
|
${!selectedCategory
|
||||||
|
? 'text-white bg-zinc-700/60'
|
||||||
|
: 'text-zinc-400 hover:text-white hover:bg-zinc-800/30'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{t('all')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Category buttons */}
|
||||||
|
{categories.map((category) => {
|
||||||
|
const isActive = selectedCategory === category;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={category}
|
||||||
|
onClick={() => handleCategoryClick(category)}
|
||||||
|
className={`
|
||||||
|
relative whitespace-nowrap rounded-full py-2 px-4
|
||||||
|
transition-all duration-200 ease-in-out text-sm tracking-wide
|
||||||
|
${isActive
|
||||||
|
? 'text-white bg-zinc-700/60'
|
||||||
|
: 'text-zinc-400 hover:text-white hover:bg-zinc-800/30'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{t(`categories.${category}`)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
|
export { CategoryFilter } from './CategoryFilter';
|
||||||
export { default as PortfolioCard } from './PortfolioCard';
|
export { default as PortfolioCard } from './PortfolioCard';
|
||||||
export { default as PortfolioGrid } from './PortfolioGrid';
|
export { default as PortfolioGrid } from './PortfolioGrid';
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { createServerClient } from '@supabase/ssr';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function createClient(request: NextRequest) {
|
||||||
|
let response = NextResponse.next({
|
||||||
|
request: {
|
||||||
|
headers: request.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const supabase = createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return request.cookies.getAll();
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
request.cookies.set(name, value)
|
||||||
|
);
|
||||||
|
response = NextResponse.next({
|
||||||
|
request: {
|
||||||
|
headers: request.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
response.cookies.set(name, value, options)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return { supabase, response };
|
||||||
|
}
|
||||||
@@ -450,11 +450,12 @@
|
|||||||
"filters": {
|
"filters": {
|
||||||
"all": "Alle",
|
"all": "Alle",
|
||||||
"categories": {
|
"categories": {
|
||||||
"Data Science": "Data Science",
|
|
||||||
"AI Development": "KI-Entwicklung",
|
"AI Development": "KI-Entwicklung",
|
||||||
"Integration": "Integration",
|
"IoT": "IoT",
|
||||||
"Full-Stack Development": "Full-Stack Entwicklung",
|
"Full-Stack": "Full-Stack",
|
||||||
"Data Processing": "Datenverarbeitung"
|
"Enterprise Software": "Unternehmenssoftware",
|
||||||
|
"E-Commerce": "E-Commerce",
|
||||||
|
"Developer Tools": "Entwickler-Tools"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
@@ -476,12 +477,15 @@
|
|||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
"readMore": "Weiterlesen",
|
"readMore": "Weiterlesen",
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Beiträge durchsuchen..."
|
||||||
|
},
|
||||||
"loading": {
|
"loading": {
|
||||||
"posts": "Blogbeiträge werden geladen...",
|
"posts": "Blogbeiträge werden geladen...",
|
||||||
"post": "Blogbeitrag wird geladen..."
|
"post": "Blogbeitrag wird geladen..."
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"posts": "Fehler beim Laden der Blogbeiträge",
|
"posts": "Keine Beiträge gefunden",
|
||||||
"post": "Der gesuchte Blogbeitrag existiert nicht oder wurde verschoben."
|
"post": "Der gesuchte Blogbeitrag existiert nicht oder wurde verschoben."
|
||||||
},
|
},
|
||||||
"backToBlog": "Zurück zum Blog",
|
"backToBlog": "Zurück zum Blog",
|
||||||
|
|||||||
@@ -465,11 +465,12 @@
|
|||||||
"filters": {
|
"filters": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"categories": {
|
"categories": {
|
||||||
"Data Science": "Data Science",
|
|
||||||
"AI Development": "AI Development",
|
"AI Development": "AI Development",
|
||||||
"Integration": "Integration",
|
"IoT": "IoT",
|
||||||
"Full-Stack Development": "Full-Stack Development",
|
"Full-Stack": "Full-Stack",
|
||||||
"Data Processing": "Data Processing"
|
"Enterprise Software": "Enterprise Software",
|
||||||
|
"E-Commerce": "E-Commerce",
|
||||||
|
"Developer Tools": "Developer Tools"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
@@ -491,12 +492,15 @@
|
|||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
"readMore": "Read More",
|
"readMore": "Read More",
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Search posts..."
|
||||||
|
},
|
||||||
"loading": {
|
"loading": {
|
||||||
"posts": "Loading blog posts...",
|
"posts": "Loading blog posts...",
|
||||||
"post": "Loading blog post..."
|
"post": "Loading blog post..."
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"posts": "Error loading blog posts",
|
"posts": "No posts found",
|
||||||
"post": "The requested blog post does not exist or has been moved."
|
"post": "The requested blog post does not exist or has been moved."
|
||||||
},
|
},
|
||||||
"backToBlog": "Back to Blog",
|
"backToBlog": "Back to Blog",
|
||||||
|
|||||||
@@ -472,11 +472,12 @@
|
|||||||
"filters": {
|
"filters": {
|
||||||
"all": "Sve",
|
"all": "Sve",
|
||||||
"categories": {
|
"categories": {
|
||||||
"Data Science": "Data Science",
|
|
||||||
"AI Development": "AI Razvoj",
|
"AI Development": "AI Razvoj",
|
||||||
"Integration": "Integracija",
|
"IoT": "IoT",
|
||||||
"Full-Stack Development": "Full-Stack Razvoj",
|
"Full-Stack": "Full-Stack",
|
||||||
"Data Processing": "Obrada podataka"
|
"Enterprise Software": "Korporativni Softver",
|
||||||
|
"E-Commerce": "E-trgovina",
|
||||||
|
"Developer Tools": "Alati za Razvoj"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
@@ -498,12 +499,15 @@
|
|||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
"readMore": "Procitaj vise",
|
"readMore": "Procitaj vise",
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Pretrazi postove..."
|
||||||
|
},
|
||||||
"loading": {
|
"loading": {
|
||||||
"posts": "Ucitavanje blog postova...",
|
"posts": "Ucitavanje blog postova...",
|
||||||
"post": "Ucitavanje blog posta..."
|
"post": "Ucitavanje blog posta..."
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"posts": "Greska pri ucitavanju blog postova",
|
"posts": "Nema pronađenih postova",
|
||||||
"post": "Trazeni blog post ne postoji ili je premesten."
|
"post": "Trazeni blog post ne postoji ili je premesten."
|
||||||
},
|
},
|
||||||
"backToBlog": "Nazad na Blog",
|
"backToBlog": "Nazad na Blog",
|
||||||
|
|||||||
Reference in New Issue
Block a user