diff --git a/app/blog/layout.tsx b/app/blog/layout.tsx new file mode 100644 index 0000000..5a06427 --- /dev/null +++ b/app/blog/layout.tsx @@ -0,0 +1,15 @@ +import { Metadata } from 'next' +import { getAllPosts } from '@/lib/markdown' +import BlogPageClient from './page' + +export const metadata: Metadata = { + title: 'Blog', + description: 'Toate articolele din blog', +} + +export default async function BlogLayout() { + const posts = await getAllPosts() + const allTags = Array.from(new Set(posts.flatMap((post) => post.frontmatter.tags))).sort() + + return +} diff --git a/app/blog/page.tsx b/app/blog/page.tsx index f12d075..3183960 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -1,94 +1,179 @@ -import { Metadata } from 'next' -import Link from 'next/link' -import { getAllPosts } from '@/lib/markdown' -import { formatDate } from '@/lib/utils' +'use client' -export const metadata: Metadata = { - title: 'Blog', - description: 'Toate articolele din blog', +import { useMemo, useState } from 'react' +import { Post } from '@/lib/types/frontmatter' +import { BlogCard } from '@/components/blog/blog-card' +import { SearchBar } from '@/components/blog/search-bar' +import { SortDropdown } from '@/components/blog/sort-dropdown' +import { TagFilter } from '@/components/blog/tag-filter' +import { Navbar } from '@/components/blog/navbar' + +interface BlogPageClientProps { + posts: Post[] + allTags: string[] } -function PostCard({ post }: { post: any }) { - return ( -
-
- {post.frontmatter.image && ( -
- {post.frontmatter.title} -
- )} -
-
- - - {post.readingTime} min citire - - {post.frontmatter.author} -
-

- - {post.frontmatter.title} - -

-

{post.frontmatter.description}

- {post.frontmatter.tags && post.frontmatter.tags.length > 0 && ( -
- {post.frontmatter.tags.map((tag: string) => ( - - #{tag} - - ))} -
- )} - - Citește articolul complet - - - - -
-
-
+type SortOption = 'newest' | 'oldest' | 'title' + +export default function BlogPageClient({ posts, allTags }: BlogPageClientProps) { + const [searchQuery, setSearchQuery] = useState('') + const [selectedTags, setSelectedTags] = useState([]) + const [sortBy, setSortBy] = useState('newest') + const [currentPage, setCurrentPage] = useState(1) + const postsPerPage = 9 + + const filteredAndSortedPosts = useMemo(() => { + let result = posts.filter((post) => { + const matchesSearch = + searchQuery === '' || + post.frontmatter.title.toLowerCase().includes(searchQuery.toLowerCase()) || + post.frontmatter.description.toLowerCase().includes(searchQuery.toLowerCase()) + + const matchesTags = + selectedTags.length === 0 || + selectedTags.every((tag) => post.frontmatter.tags.includes(tag)) + + return matchesSearch && matchesTags + }) + + result.sort((a, b) => { + switch (sortBy) { + case 'oldest': + return new Date(a.frontmatter.date).getTime() - new Date(b.frontmatter.date).getTime() + case 'title': + return a.frontmatter.title.localeCompare(b.frontmatter.title) + case 'newest': + default: + return new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime() + } + }) + + return result + }, [posts, searchQuery, selectedTags, sortBy]) + + const totalPages = Math.ceil(filteredAndSortedPosts.length / postsPerPage) + const paginatedPosts = filteredAndSortedPosts.slice( + (currentPage - 1) * postsPerPage, + currentPage * postsPerPage ) -} -function BlogFilters({ totalPosts }: { totalPosts: number }) { - return ( -
-
-
-

Articole Blog

-

- {totalPosts} {totalPosts === 1 ? 'articol' : 'articole'} publicate -

-
-
-
- ) -} - -export default async function BlogPage() { - const posts = await getAllPosts() - - if (posts.length === 0) { - return ( -
-

Blog

-

Nu există articole publicate încă.

- - Înapoi la pagina principală - -
+ const toggleTag = (tag: string) => { + setSelectedTags((prev) => + prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag] ) + setCurrentPage(1) } return ( -
- -
- {posts.map((post) => ( - - ))} +
+ + +
+ {/* Header */} +
+

+ DATABASE QUERY // SEARCH RESULTS +

+

+ > BLOG ARCHIVE_ +

+
+ + {/* Search Bar */} +
+
+ { + setSearchQuery(value) + setCurrentPage(1) + }} + /> + +
+
+ + {/* Tag Filters */} + { + setSelectedTags([]) + setCurrentPage(1) + }} + /> + + {/* Results Count */} +
+

+ FOUND {filteredAndSortedPosts.length} {filteredAndSortedPosts.length === 1 ? 'POST' : 'POSTS'} +

+
+ + {/* Blog Grid */} + {paginatedPosts.length > 0 ? ( +
+ {paginatedPosts.map((post, index) => { + const hasImage = !!post.frontmatter.image + let variant: 'image-top' | 'image-side' | 'text-only' + + if (!hasImage) { + variant = 'text-only' + } else { + variant = index % 3 === 1 ? 'image-side' : 'image-top' + } + + return + })} +
+ ) : ( +
+

+ NO POSTS FOUND // TRY DIFFERENT SEARCH TERMS +

+
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+
+ +
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( + + ))} +
+ +
+
+ )}
) diff --git a/app/globals.css b/app/globals.css index 073c34a..2f0a761 100644 --- a/app/globals.css +++ b/app/globals.css @@ -17,6 +17,11 @@ --text-muted: 100 116 139; --border-primary: 203 213 225; --border-subtle: 226 232 240; + + --neon-pink: #8b4a5e; + --neon-cyan: #4a7b85; + --neon-purple: #6b5583; + --neon-magenta: #8b4a7e; } .dark { @@ -29,6 +34,11 @@ --text-muted: 100 116 139; --border-primary: 71 85 105; --border-subtle: 30 41 59; + + --neon-pink: #9b5a6e; + --neon-cyan: #5a8b95; + --neon-purple: #7b6593; + --neon-magenta: #9b5a8e; } } @@ -247,4 +257,70 @@ filter: url('#noise'); } } + + /* SCP-style subtle flicker hover */ + @keyframes scp-flicker { + 0%, 100% { + border-color: rgb(71 85 105); + box-shadow: 0 0 0 rgba(90, 139, 149, 0); + transform: translate(0, 0); + } + 20% { + border-color: var(--neon-cyan); + box-shadow: 0 0 3px rgba(90, 139, 149, 0.3); + transform: translate(-0.5px, 0); + } + 40% { + border-color: rgb(71 85 105); + box-shadow: 0 0 0 rgba(90, 139, 149, 0); + transform: translate(0, 0); + } + 60% { + border-color: var(--neon-cyan); + box-shadow: 0 0 2px rgba(90, 139, 149, 0.2); + transform: translate(0.5px, 0); + } + } + + .cyber-glitch-hover { + transition: all 0.15s ease; + position: relative; + } + + .cyber-glitch-hover:hover { + animation: scp-flicker 150ms ease-in-out 3; + border-color: var(--neon-cyan) !important; + box-shadow: 0 1px 4px rgba(90, 139, 149, 0.15), inset 0 0 8px rgba(90, 139, 149, 0.05); + } + + /* Navbar hide on scroll */ + .navbar-hidden { + transform: translateY(-100%); + transition: transform 0.3s ease-in-out; + } + + .navbar-visible { + transform: translateY(0); + transition: transform 0.3s ease-in-out; + } + + /* Cyberpunk neon glow on focus - 80s style */ + .cyber-focus:focus { + outline: none; + box-shadow: + 0 0 10px var(--neon-cyan), + 0 0 20px rgba(0, 255, 255, 0.5), + inset 0 0 10px rgba(0, 255, 255, 0.1); + border-color: var(--neon-cyan); + } + + .cyber-focus-pink:focus { + outline: none; + box-shadow: + 0 0 10px var(--neon-pink), + 0 0 20px rgba(255, 0, 128, 0.5), + inset 0 0 10px rgba(255, 0, 128, 0.1); + border-color: var(--neon-pink); + } } + diff --git a/app/layout.tsx b/app/layout.tsx index 153a0bd..bb1c3f8 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -43,18 +43,20 @@ export default function RootLayout({ storageKey="blog-theme" disableTransitionOnChange={false} > - {children} +
+
{children}
- {/* Footer - from worktree-agent-1 */} -
-
-
-

- © 2025 // BLOG & PORTOFOLIU // ALL RIGHTS RESERVED -

+ {/* Footer - from worktree-agent-1 */} +
+
+
+

+ © 2025 // BLOG & PORTOFOLIU // ALL RIGHTS RESERVED +

+
-
-
+ +
diff --git a/components/blog/blog-card.tsx b/components/blog/blog-card.tsx new file mode 100644 index 0000000..9f34e44 --- /dev/null +++ b/components/blog/blog-card.tsx @@ -0,0 +1,125 @@ +import Link from 'next/link' +import Image from 'next/image' +import { Post } from '@/lib/types/frontmatter' +import { formatDate } from '@/lib/utils' + +interface BlogCardProps { + post: Post + variant: 'image-top' | 'image-side' | 'text-only' +} + +export function BlogCard({ post, variant }: BlogCardProps) { + const hasImage = !!post.frontmatter.image + + if (!hasImage || variant === 'text-only') { + return ( + +
+
+ + {post.frontmatter.category} // {formatDate(post.frontmatter.date)} + +
+

+ {post.frontmatter.title} +

+

+ {post.frontmatter.description} +

+
+ {post.frontmatter.tags.map((tag) => ( + + #{tag} + + ))} +
+ + > READ [{post.readingTime}MIN] + +
+ + ) + } + + if (variant === 'image-side') { + return ( + +
+
+
+ {post.frontmatter.title} +
+
+
+
+ + {post.frontmatter.category} // {formatDate(post.frontmatter.date)} + +
+

+ {post.frontmatter.title} +

+

+ {post.frontmatter.description} +

+
+ {post.frontmatter.tags.map((tag) => ( + + #{tag} + + ))} +
+ + > READ [{post.readingTime}MIN] + +
+
+
+ + ) + } + + return ( + +
+
+ {post.frontmatter.title} +
+
+
+
+ + {post.frontmatter.category} // {formatDate(post.frontmatter.date)} + +
+

+ {post.frontmatter.title} +

+

+ {post.frontmatter.description} +

+
+ {post.frontmatter.tags.map((tag) => ( + + #{tag} + + ))} +
+ + > READ [{post.readingTime}MIN] + +
+
+ + ) +} diff --git a/components/blog/navbar.tsx b/components/blog/navbar.tsx new file mode 100644 index 0000000..13ed66f --- /dev/null +++ b/components/blog/navbar.tsx @@ -0,0 +1,52 @@ +'use client' + +import { useEffect, useState } from 'react' +import Link from 'next/link' +import { ThemeToggle } from '@/components/theme-toggle' + +export function Navbar() { + const [isVisible, setIsVisible] = useState(true) + const [lastScrollY, setLastScrollY] = useState(0) + + useEffect(() => { + const handleScroll = () => { + const currentScrollY = window.scrollY + + if (currentScrollY < 10) { + setIsVisible(true) + } else if (currentScrollY > lastScrollY) { + setIsVisible(false) + } else { + setIsVisible(true) + } + + setLastScrollY(currentScrollY) + } + + window.addEventListener('scroll', handleScroll, { passive: true }) + return () => window.removeEventListener('scroll', handleScroll) + }, [lastScrollY]) + + return ( + + ) +} diff --git a/components/blog/search-bar.tsx b/components/blog/search-bar.tsx new file mode 100644 index 0000000..4a64b30 --- /dev/null +++ b/components/blog/search-bar.tsx @@ -0,0 +1,19 @@ +interface SearchBarProps { + searchQuery: string + onSearchChange: (value: string) => void +} + +export function SearchBar({ searchQuery, onSearchChange }: SearchBarProps) { + return ( +
+ > + onSearchChange(e.target.value)} + className="flex-1 bg-transparent font-mono text-zinc-100 dark:text-zinc-100 px-2 py-3 focus:outline-none placeholder:text-zinc-600 uppercase text-sm" + /> +
+ ) +} diff --git a/components/blog/sort-dropdown.tsx b/components/blog/sort-dropdown.tsx new file mode 100644 index 0000000..3b21ac2 --- /dev/null +++ b/components/blog/sort-dropdown.tsx @@ -0,0 +1,20 @@ +type SortOption = 'newest' | 'oldest' | 'title' + +interface SortDropdownProps { + sortBy: SortOption + onSortChange: (value: SortOption) => void +} + +export function SortDropdown({ sortBy, onSortChange }: SortDropdownProps) { + return ( + + ) +} diff --git a/components/blog/tag-filter.tsx b/components/blog/tag-filter.tsx new file mode 100644 index 0000000..f7a19be --- /dev/null +++ b/components/blog/tag-filter.tsx @@ -0,0 +1,41 @@ +interface TagFilterProps { + allTags: string[] + selectedTags: string[] + onToggleTag: (tag: string) => void + onClearTags: () => void +} + +export function TagFilter({ allTags, selectedTags, onToggleTag, onClearTags }: TagFilterProps) { + if (allTags.length === 0) return null + + return ( +
+

+ FILTER BY TAG +

+
+ {allTags.map((tag) => ( + + ))} +
+ {selectedTags.length > 0 && ( + + )} +
+ ) +} diff --git a/content/blog/test-complet.md b/content/blog/test-complet.md index 2caf6c6..a4063e8 100644 --- a/content/blog/test-complet.md +++ b/content/blog/test-complet.md @@ -5,7 +5,7 @@ date: "2025-01-15" author: "Test Author" category: "Tutorial" tags: ["markdown", "test", "demo"] -image: "/images/test.jpg" +image: "/38636.jpg" draft: false --- diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/public/38636.jpg b/public/38636.jpg new file mode 100644 index 0000000..4a8d2a8 Binary files /dev/null and b/public/38636.jpg differ