5 Commits

Author SHA1 Message Date
RJ
5e9093cf9c 🖼️ fix build 2025-11-13 16:42:05 +02:00
RJ
82b77be57a 🖼️ update blog post styles 2025-11-13 16:04:17 +02:00
RJ
bc745cfa8b -> Added claude contexts 2025-11-12 16:17:55 +02:00
RJ
06155c9dbe - fixed routing 2025-11-12 16:17:29 +02:00
87cf7946f9 Merge pull request '🖼️ blog listing styles updates' (#3) from feat/03-glob-listing-agent-1 into master
Reviewed-on: http://192.168.1.53:3000/raresj/mypage/pulls/3
2025-11-12 12:46:15 +00:00
22 changed files with 1886 additions and 360 deletions

View File

@@ -0,0 +1,758 @@
---
name: nextjs-coding-standards
description: Next.js 16 coding standards including file naming conventions, API patterns, theming, styling guidelines, and directory structure. Use when writing or reviewing code.
allowed-tools: Read, Grep, Glob
---
# Next.js 16 Coding Standards
Reference this skill when writing or reviewing code to ensure consistency with project conventions.
---
## File Naming Conventions
### Files and Directories
**Use kebab-case for all file names:**
```
✅ user-profile.tsx
✅ blog-post-card.tsx
✅ theme-toggle.tsx
❌ UserProfile.tsx
❌ blogPostCard.tsx
❌ ThemeToggle.tsx
```
**Why kebab-case?**
- Cross-platform compatibility (Windows vs Unix)
- URL-friendly (file names often map to routes)
- Easier to parse and read
- Industry standard for Next.js projects
**Special Next.js Files:**
```
page.tsx # Route pages
layout.tsx # Layout components
not-found.tsx # 404 pages
loading.tsx # Loading states
error.tsx # Error boundaries
route.ts # API route handlers
```
### Component Names (Inside Files)
**Use PascalCase for component names:**
```typescript
// File: user-profile.tsx
export function UserProfile() {
return <div>...</div>
}
// File: blog-post-card.tsx
export default function BlogPostCard() {
return <article>...</article>
}
```
### Variables, Functions, Props
**Use camelCase:**
```typescript
// Variables
const userSettings = {}
const isLoading = false
// Functions
function handleSubmit() {}
function formatDate(date: string) {}
// Props
interface ButtonProps {
onClick: () => void
isDisabled: boolean
className?: string
}
// Custom Hooks
function useTheme() {}
function useMarkdown() {}
```
### Constants
**Use SCREAMING_SNAKE_CASE:**
```typescript
const API_BASE_URL = "https://api.example.com"
const MAX_RETRIES = 3
const DEFAULT_LOCALE = "ro-RO"
```
---
## Next.js 16 API Patterns
### Route Handlers
**Use Route Handlers (not legacy API Routes):**
```typescript
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
// Export named functions for HTTP methods
export async function GET(request: NextRequest) {
const posts = await getAllPosts()
return NextResponse.json({ posts })
}
export async function POST(request: NextRequest) {
const body = await request.json()
// Process request
return NextResponse.json({ success: true }, { status: 201 })
}
```
### Type-Safe Validation with Zod
**Always validate input with Zod:**
```typescript
import { z } from 'zod'
import { NextRequest, NextResponse } from 'next/server'
const bodySchema = z.object({
title: z.string().min(1).max(200),
content: z.string(),
tags: z.array(z.string()).max(3),
})
export async function POST(request: NextRequest) {
const json = await request.json()
const parsed = bodySchema.safeParse(json)
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error },
{ status: 400 }
)
}
// parsed.data is fully typed
const { title, content, tags } = parsed.data
// ... business logic
}
```
**Key Points:**
- Use `safeParse()` instead of `parse()` to avoid try/catch
- Return structured error responses
- Use appropriate HTTP status codes
- Infer TypeScript types from Zod schemas
### Error Handling
**Return meaningful status codes:**
```typescript
200 // Success
201 // Created
400 // Bad Request (validation errors)
401 // Unauthorized
403 // Forbidden
404 // Not Found
500 // Internal Server Error
```
**Structured error responses:**
```typescript
return NextResponse.json(
{
error: 'Resource not found',
code: 'NOT_FOUND',
timestamp: new Date().toISOString()
},
{ status: 404 }
)
```
---
## Theming Patterns
### next-themes Setup
**Root Layout (Server Component):**
```typescript
// app/layout.tsx
import { ThemeProvider } from 'next-themes'
export default function RootLayout({ children }) {
return (
<html lang="ro" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem={false}
storageKey="blog-theme"
>
{children}
</ThemeProvider>
</body>
</html>
)
}
```
**Critical:** Always add `suppressHydrationWarning` to `<html>` tag.
### Theme Toggle Component
**Avoid hydration mismatches with mounted state:**
```typescript
// components/theme-toggle.tsx
'use client'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
// Prevent hydration mismatch
useEffect(() => setMounted(true), [])
if (!mounted) {
return <div className="w-9 h-9 animate-pulse" />
}
return (
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
{theme === 'dark' ? '🌙' : '☀️'}
</button>
)
}
```
**Pattern:** Always check `mounted` state before rendering theme-dependent UI.
### CSS Variables for Theme Tokens
**Define in globals.css:**
```css
@layer base {
:root {
--bg-primary: 255 255 255;
--bg-secondary: 248 250 252;
--text-primary: 15 23 42;
--text-secondary: 51 65 85;
}
.dark {
--bg-primary: 24 24 27;
--bg-secondary: 15 23 42;
--text-primary: 241 245 249;
--text-secondary: 203 213 225;
}
}
```
**Use in components:**
```tsx
<div className="bg-[rgb(var(--bg-primary))] text-[rgb(var(--text-primary))]">
Theme-aware component
</div>
```
### Tailwind Configuration
**Enable class-based dark mode:**
```javascript
// tailwind.config.js
module.exports = {
darkMode: 'class', // Required for next-themes
theme: {
extend: {
colors: {
'dark-primary': '#18181b',
'accent': {
DEFAULT: '#164e63',
hover: '#155e75',
},
},
},
},
}
```
**Use dark: variant:**
```tsx
<div className="bg-slate-100 dark:bg-zinc-900 text-slate-900 dark:text-slate-100">
Automatically switches based on theme
</div>
```
---
## Styling Guidelines (Tailwind CSS)
### Utility-First Philosophy
**Prefer inline utilities over custom CSS:**
```tsx
// ✅ Good: Inline utilities
<article className="border-4 border-slate-800 p-6 bg-zinc-900 hover:border-cyan-900">
<h2 className="text-2xl font-bold uppercase tracking-wider">Title</h2>
</article>
// ❌ Avoid: @apply (increases bundle size)
/* styles.css */
.card {
@apply border-4 border-slate-800 p-6 bg-zinc-900;
}
```
**Exception:** Only use `@apply` for truly global base styles in `globals.css`.
### Component Extraction
**Extract to React components when reused:**
```typescript
// ✅ Extract repeated patterns to components
export function Card({ children, className = "" }) {
return (
<div className={`border-4 border-slate-800 p-6 bg-zinc-900 ${className}`}>
{children}
</div>
)
}
// Usage
<Card className="hover:border-cyan-900">
<h2>Title</h2>
</Card>
```
**Don't extract:** One-off components or single-use patterns.
### Class Organization
**Group utilities logically:**
```tsx
// Layout → Spacing → Colors → Typography → Effects
<div className="
flex flex-col // Layout
gap-4 p-6 // Spacing
bg-zinc-900 // Colors
border-4 border-slate-800
text-slate-100
font-mono text-sm // Typography
uppercase tracking-wider
hover:border-cyan-900 // Effects
transition-colors
">
```
**Tip:** Use Prettier plugin for automatic Tailwind class sorting.
### Responsive Design
**Mobile-first approach:**
```tsx
// Base classes = mobile, add breakpoints for larger screens
<div className="
flex-col // Mobile: vertical stack
md:flex-row // Tablet+: horizontal layout
lg:gap-8 // Desktop: more spacing
">
```
**Standard breakpoints:**
```
sm: 640px // Small tablets
md: 768px // Tablets
lg: 1024px // Laptops
xl: 1280px // Desktops
2xl: 1536px // Large screens
```
### Conditional Styling
**For complex conditions, use variants:**
```typescript
const cardVariants = {
default: "border-slate-800 bg-zinc-900",
highlighted: "border-cyan-600 bg-cyan-950",
error: "border-red-600 bg-red-950",
}
<Card className={cardVariants[variant]} />
```
---
## Directory Structure Standards
### Project Organization
```
app/
├── (auth)/ # Route groups (no URL segment)
│ ├── login/
│ └── register/
├── api/ # API routes
│ └── posts/
│ └── route.ts
├── blog/
│ ├── page.tsx
│ └── [slug]/
│ └── page.tsx
├── @breadcrumbs/ # Parallel routes
│ └── default.tsx
├── layout.tsx # Root layout
├── globals.css
└── page.tsx
components/
├── blog/ # Domain-specific components
│ ├── post-card.tsx
│ └── markdown-renderer.tsx
├── layout/ # Layout components
│ ├── header.tsx
│ └── footer.tsx
└── ui/ # Reusable UI primitives
├── button.tsx
└── card.tsx
lib/
├── api/ # API clients, fetch wrappers
│ └── client.ts
├── types/ # TypeScript type definitions
│ └── post.ts
├── markdown.ts # Business logic modules
├── seo.ts
└── utils.ts # Pure utility functions
public/
├── blog/ # Blog-specific assets
│ └── images/
└── icons/
content/ # Content files (outside app/)
└── blog/
└── posts.md
```
### lib/ Organization
**Modules in lib/:**
- Substantial business logic (markdown.ts, seo.ts)
- API clients and data fetching
- Database connections
- Authentication logic
**Utils in lib/utils.ts:**
- Pure helper functions
- Formatters (formatDate, formatCurrency)
- Validators (isEmail, isValidUrl)
- String manipulations
**Types in lib/types/:**
- Shared TypeScript interfaces
- API response types
- Domain models
- Colocated with their modules when possible
### Component Organization
**By domain/feature:**
```
components/
├── blog/ # Blog-specific
│ ├── post-card.tsx
│ ├── post-list.tsx
│ └── markdown-renderer.tsx
├── auth/ # Auth-specific
│ ├── login-form.tsx
│ └── signup-form.tsx
└── ui/ # Reusable primitives
├── button.tsx
├── card.tsx
└── input.tsx
```
**Not by type:**
```
❌ Don't organize like this:
components/
├── forms/
├── buttons/
├── cards/
└── modals/
```
### Public Assets
**Organize by feature:**
```
public/
├── blog/
│ ├── images/
│ └── thumbnails/
├── icons/
│ ├── social/
│ └── ui/
└── fonts/
```
**Naming conventions:**
- Use descriptive names: `hero-background.jpg` not `img1.jpg`
- Use kebab-case: `user-avatar.png`
- Include dimensions for images: `logo-512x512.png`
---
## TypeScript Best Practices
### Type Safety
**Avoid `any`:**
```typescript
// ❌ Bad
function processData(data: any) { }
// ✅ Good
function processData(data: unknown) {
if (typeof data === 'string') {
// TypeScript knows data is string here
}
}
// ✅ Better: Use specific types
interface PostData {
title: string
content: string
}
function processData(data: PostData) { }
```
### Infer Types from Zod
**Don't duplicate types:**
```typescript
import { z } from 'zod'
// Define schema once
const postSchema = z.object({
title: z.string(),
content: z.string(),
tags: z.array(z.string()),
})
// Infer TypeScript type
type Post = z.infer<typeof postSchema>
// Now you have both runtime validation and compile-time types
```
### Type Imports
**Use type imports for types only:**
```typescript
// ✅ Good: Explicit type import
import type { Metadata } from 'next'
import type { Post } from '@/lib/types/post'
// ✅ Mixed: Regular and type imports
import { getAllPosts } from '@/lib/markdown'
import type { Post } from '@/lib/types/post'
```
---
## Next.js 16 Specific Patterns
### Async Server Components
**Fetch data directly in components:**
```typescript
// app/blog/page.tsx
export default async function BlogPage() {
// Server-side data fetching (no useEffect needed)
const posts = await getAllPosts()
return (
<div>
{posts.map(post => (
<PostCard key={post.slug} post={post} />
))}
</div>
)
}
```
### Static Generation
**Use generateStaticParams for dynamic routes:**
```typescript
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map(post => ({
slug: post.slug.split('/'), // For catch-all routes
}))
}
export async function generateMetadata({ params }) {
const post = getPostBySlug(params.slug.join('/'))
return {
title: post.frontmatter.title,
description: post.frontmatter.description,
}
}
export default async function PostPage({ params }) {
const post = getPostBySlug(params.slug.join('/'))
return <article>{/* render post */}</article>
}
```
### Client Components
**Minimize 'use client' usage:**
```typescript
// ❌ Unnecessary client component
'use client'
export function StaticCard({ title }) {
return <div>{title}</div>
}
// ✅ Keep as server component (default)
export function StaticCard({ title }) {
return <div>{title}</div>
}
// ✅ Only use 'use client' when necessary
'use client'
import { useState } from 'react'
export function InteractiveCard({ title }) {
const [isOpen, setIsOpen] = useState(false)
return (
<div onClick={() => setIsOpen(!isOpen)}>
{title}
</div>
)
}
```
**When to use 'use client':**
- Using React hooks (useState, useEffect, etc.)
- Using event handlers (onClick, onChange, etc.)
- Using browser APIs (window, localStorage, etc.)
- Using context consumers
### Parallel Routes
**Use for layout composition:**
```typescript
// app/layout.tsx
export default function RootLayout({
children,
breadcrumbs, // From @breadcrumbs parallel route
}: {
children: React.ReactNode
breadcrumbs: React.ReactNode
}) {
return (
<>
{breadcrumbs}
<main>{children}</main>
</>
)
}
```
---
## Common Pitfalls
### 1. Hydration Mismatches
**Problem:** Theme-dependent content renders differently on server vs client.
**Solution:** Use mounted state pattern (see Theming section).
### 2. Image Paths
**Problem:** Incorrect public asset paths.
```typescript
// ❌ Wrong
<Image src="blog/image.jpg" />
// ✅ Correct
<Image src="/blog/image.jpg" /> // Leading slash
```
### 3. Dynamic Route Params
**Problem:** Forgetting slug should be an array for catch-all routes.
```typescript
// app/blog/[...slug]/page.tsx
export async function generateStaticParams() {
// ❌ Wrong
return posts.map(post => ({ slug: post.slug }))
// ✅ Correct
return posts.map(post => ({ slug: post.slug.split('/') }))
}
```
### 4. Over-using Client Components
**Problem:** Adding 'use client' unnecessarily.
**Solution:** Keep components as Server Components by default. Only add 'use client' when you need hooks, events, or browser APIs.
### 5. Date Formats
**Problem:** Inconsistent date formatting.
**Solution:** Use consistent ISO format (YYYY-MM-DD) in data, format for display:
```typescript
// In frontmatter
date: "2025-01-15"
// For display
formatDate(post.frontmatter.date) // "15 ianuarie 2025" (Romanian)
```
---
## Reference
For project-specific architecture and design philosophy, see `CLAUDE.md` at the root of this repository.
This skill focuses on coding conventions and standards. For architecture patterns, markdown processing, and industrial design aesthetic guidelines, refer to the main documentation.

332
CLAUDE.md Normal file
View File

@@ -0,0 +1,332 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a **Next.js 16** blog/portfolio application built with **TypeScript**, **Tailwind CSS**, and **React 19**. The project uses **App Router** with Static Site Generation (SSG) for blog posts stored as Markdown files.
**Design Philosophy:** Industrial/SCP-inspired aesthetic with terminal/cyberpunk elements. Sharp edges, thick borders, monospace fonts, darker color palettes (slate/zinc/cyan/emerald tones). No modern Material UI feel - think government documents, classified files, brutal utilitarian design.
## Development Commands
```bash
# Development server (runs on port 3030)
npm run dev
# Production build
npm run build
# Start production server
npm run start
# Lint code
npm run lint
# Validate all markdown posts (frontmatter, format, tags)
npm run validate-posts
```
## Architecture Overview
### Next.js 16 App Router Structure
```
app/
├── @breadcrumbs/ # Parallel route for breadcrumb navigation
│ ├── default.tsx # Auto-generated breadcrumbs
│ ├── blog/[...slug]/ # Post-specific breadcrumbs with titles
│ ├── tags/[tag]/ # Tag-specific breadcrumbs
│ └── about/ # Static breadcrumbs
├── blog/
│ ├── page.tsx # Blog listing with all posts
│ └── [...slug]/ # Dynamic post routes (supports nested paths)
│ ├── page.tsx # Post rendering with SSG
│ └── not-found.tsx # Custom 404 for missing posts
├── about/page.tsx
├── layout.tsx # Root layout with metadata, fonts, breadcrumbs slot
└── page.tsx # Landing page (hero + featured posts)
```
**Key Architectural Patterns:**
1. **Parallel Routes:** `@breadcrumbs` slot renders dynamic navigation based on current route without prop drilling
2. **Catch-all Routes:** `[...slug]` supports nested blog posts (e.g., `/blog/tech/article-name`)
3. **Static Generation:** `generateStaticParams()` pre-renders all blog posts at build time
4. **Server Components by Default:** All components are RSC unless marked with `'use client'`
### Markdown System
```
content/blog/ # Markdown files (supports nested directories)
├── example.md
└── tech/
└── article.md
lib/
├── markdown.ts # Core markdown utilities
│ ├── getPostBySlug() # Read single post with path sanitization
│ ├── getAllPosts() # Get all posts, sorted by date, recursive
│ ├── getRelatedPosts() # Find similar posts by tags
│ └── validateFrontmatter()
├── types/frontmatter.ts # TypeScript interfaces for Post, FrontMatter
└── utils.ts # formatDate(), formatRelativeDate(), generateExcerpt()
```
**Frontmatter Schema:**
```yaml
---
title: string # Required
description: string # Required
date: "YYYY-MM-DD" # Required, ISO format
author: string # Required
tags: [string, string?, string?] # Max 3 tags
image?: string # Optional hero image
draft?: boolean # Exclude from listings if true
---
```
**Security:** Path sanitization prevents directory traversal attacks. All file reads use `path.resolve()` and validate paths stay within `content/blog/`.
### Components Organization
```
components/
├── blog/
│ └── MarkdownRenderer.tsx # Client component for rendering markdown
│ # Custom components: images (Next Image),
│ # links (external vs internal), code blocks
├── layout/
│ ├── Breadcrumbs.tsx # Client component, uses usePathname()
│ └── BreadcrumbsSchema.tsx # Schema.org structured data for SEO
└── [future components]
```
## Coding Standards for Next.js 16
### File Naming Conventions
**Files and Directories:**
- Use **kebab-case** for all file names: `user-profile.tsx`, `blog-post.tsx`
- Special Next.js files: `page.tsx`, `layout.tsx`, `not-found.tsx`, `loading.tsx`
**Component Names (inside files):**
- Use **PascalCase**: `export function UserProfile()`, `export default BlogPost`
**Variables, Functions, Props:**
- Use **camelCase**: `const userSettings = {}`, `function handleSubmit() {}`
- Hooks: `useTheme`, `useMarkdown`
**Constants:**
- Use **SCREAMING_SNAKE_CASE**: `const API_BASE_URL = "..."`
**Why kebab-case for files?**
- Cross-platform compatibility (Windows vs Unix)
- URL-friendly (file names often map to routes)
- Easier to parse and read
### Theme Management & Reusability
**Recommended Pattern:** Use `next-themes` library for dark/light mode
```typescript
// Root layout.tsx
import { ThemeProvider } from 'next-themes'
export default function RootLayout({ children }) {
return (
<html suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem={false}
storageKey="blog-theme"
>
{children}
</ThemeProvider>
</body>
</html>
)
}
```
**Client Component for Toggle:**
```typescript
// components/theme-toggle.tsx
'use client'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
// Prevent hydration mismatch
useEffect(() => setMounted(true), [])
if (!mounted) return <div>...</div>
return <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
Toggle
</button>
}
```
**Tailwind Configuration:**
```javascript
// tailwind.config.js
module.exports = {
darkMode: 'class', // Use 'class' strategy for next-themes
theme: {
extend: {
colors: {
// Define custom colors for consistency
'dark-primary': '#18181b',
'accent': { DEFAULT: '#164e63', hover: '#155e75' }
}
}
}
}
```
**CSS Variables Pattern:**
```css
/* globals.css */
:root {
--bg-primary: 255 255 255;
--text-primary: 15 23 42;
}
.dark {
--bg-primary: 24 24 27;
--text-primary: 241 245 249;
}
/* Use in components */
.card {
@apply bg-[rgb(var(--bg-primary))] text-[rgb(var(--text-primary))];
}
```
### Provider Pattern Best Practices
1. **Server Component Boundary:** Keep `layout.tsx` as Server Component, wrap only `children` with Client Provider
2. **Avoid Hydration Mismatches:** Always use `suppressHydrationWarning` on `<html>` tag
3. **Client-Only Rendering:** Use `useEffect` + `mounted` state for theme-dependent UI
4. **Context Consumption:** Only components using `useTheme()` need `'use client'` directive
5. **No Prop Drilling:** Context makes theme accessible anywhere without passing props
### Next.js 16 Specific Patterns
**Async Server Components:**
```typescript
// app/blog/page.tsx
export default async function BlogPage() {
const posts = await getAllPosts() // Server-side data fetching
return <div>{posts.map(...)}</div>
}
```
**Static Generation with Dynamic Routes:**
```typescript
// app/blog/[...slug]/page.tsx
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map(post => ({ slug: post.slug.split('/') }))
}
export async function generateMetadata({ params }) {
const post = getPostBySlug(params.slug.join('/'))
return { title: post.frontmatter.title, ... }
}
```
**Parallel Routes for Layout Composition:**
```typescript
// app/layout.tsx
export default function RootLayout({
children,
breadcrumbs, // From @breadcrumbs parallel route
}: {
children: React.ReactNode
breadcrumbs: React.ReactNode
}) {
return <>
{breadcrumbs}
{children}
</>
}
```
## Project-Specific Patterns
### Markdown Processing
- **Always validate paths:** Use `sanitizePath()` to prevent directory traversal
- **Draft support:** Posts with `draft: true` are excluded from `getAllPosts()`
- **Recursive directories:** Blog posts can be organized in subdirectories (`content/blog/tech/post.md`)
- **Reading time:** Auto-calculated at 200 words/minute
- **Date handling:** Use Romanian locale (`ro-RO`) for date formatting
### SEO & Metadata
- **Every page exports `metadata`:** Use Next.js 16's `Metadata` type
- **Dynamic metadata:** Use `generateMetadata()` for blog posts
- **Structured data:** Include Schema.org `BreadcrumbList` and `BlogPosting`
- **OpenGraph images:** Reference `post.frontmatter.image` for social sharing
### Styling Guidelines
**Color Palette:**
- Backgrounds: `zinc-900`, `slate-900`, `slate-800`
- Accents: `cyan-900`, `emerald-900`, `teal-900`
- Text: `slate-100`, `slate-300`, `slate-500`
- Borders: `border-2`, `border-4` (thick, sharp)
**Design Tokens:**
- **NO rounded corners:** Use `rounded-none` or omit (default is sharp)
- **Monospace fonts:** Apply `font-mono` for terminal aesthetic
- **Uppercase labels:** Use `uppercase tracking-wider` for headers
- **Border-heavy design:** Thick borders (`border-4`) over shadows
- **Classification labels:** Add metadata like "FILE#001", "DOCUMENT LEVEL-1"
**Typography:**
- Primary font: `JetBrains Mono` (monospace)
- Headings: `font-mono font-bold uppercase`
- Body: `font-mono text-sm`
- Code blocks: Sharp borders, dark background, no syntax highlighting (for terminal feel)
## Available Subagents
Use these specialized agents via `/spec-implementation-and-review` command:
- `nextjs-specialist` - Next.js 15/16, App Router, SSG, API routes
- `ui-implementer` - UI implementation with shadcn/ui, Tailwind
- `ui-css-specialist` - CSS layouts, styling, responsive design
- `react-frontend-expert` - React components, hooks, state management
- `nodejs-typescript-engineer` - TypeScript, Node.js backend
## Common Pitfalls
1. **Hydration Mismatches with Themes:** Always use `suppressHydrationWarning` on `<html>` and check `mounted` state before rendering theme-dependent UI
2. **Image Paths:** Use `/` prefix for public assets (`/blog/image.jpg` not `blog/image.jpg`)
3. **Dynamic Routes:** Remember to return `slug` as array in `generateStaticParams()` for catch-all routes
4. **Client Components:** Minimize `'use client'` usage - only add when using hooks, event handlers, or browser APIs
5. **Path Security:** Always use `sanitizePath()` when reading markdown files
6. **Date Formats:** Use `YYYY-MM-DD` in frontmatter, convert to Romanian locale for display
7. **Port Configuration:** Dev server runs on port **3030** (not default 3000)
## Type Safety
- All utilities in `lib/` are fully typed
- Frontmatter structure enforced via `FrontMatter` interface
- Use `Post` type for blog post objects
- Avoid `any` - use `unknown` if type is truly unknown, then narrow
## Performance Notes
- **SSG by default:** All blog posts pre-rendered at build time
- **Image optimization:** Use Next.js `<Image>` component
- **Font optimization:** Google Fonts loaded via `next/font`
- **No client-side data fetching:** Markdown loaded server-side only
- **Static exports:** Pages are fully static HTML (no server required for hosting)

View File

@@ -1,4 +1,4 @@
import { Breadcrumbs } from '@/components/layout/breadcrumbs'; import { Breadcrumbs } from '@/components/layout/Breadcrumbs';
export default function AboutBreadcrumb() { export default function AboutBreadcrumb() {
return ( return (

View File

@@ -1,4 +1,4 @@
import { Breadcrumbs } from '@/components/layout/breadcrumbs'; import { Breadcrumbs } from '@/components/layout/Breadcrumbs';
import { getPostBySlug } from '@/lib/markdown'; import { getPostBySlug } from '@/lib/markdown';
interface BreadcrumbItem { interface BreadcrumbItem {

View File

@@ -1,4 +1,4 @@
import { Breadcrumbs } from '@/components/layout/breadcrumbs'; import { Breadcrumbs } from '@/components/layout/Breadcrumbs';
export default function BlogBreadcrumb() { export default function BlogBreadcrumb() {
return ( return (

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { Breadcrumbs } from '@/components/layout/breadcrumbs'; import { Breadcrumbs } from '@/components/layout/Breadcrumbs';
export default function DefaultBreadcrumb() { export default function DefaultBreadcrumb() {
return <Breadcrumbs />; return <Breadcrumbs />;

View File

@@ -1,4 +1,4 @@
import { Breadcrumbs } from '@/components/layout/breadcrumbs'; import { Breadcrumbs } from '@/components/layout/Breadcrumbs';
export default async function TagBreadcrumb({ export default async function TagBreadcrumb({
params, params,

View File

@@ -1,4 +1,4 @@
import { Breadcrumbs } from '@/components/layout/breadcrumbs'; import { Breadcrumbs } from '@/components/layout/Breadcrumbs';
export default function TagsBreadcrumb() { export default function TagsBreadcrumb() {
return ( return (

View File

@@ -3,6 +3,10 @@ import { notFound } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { getAllPosts, getPostBySlug, getRelatedPosts } from '@/lib/markdown' import { getAllPosts, getPostBySlug, getRelatedPosts } from '@/lib/markdown'
import { formatDate, formatRelativeDate } from '@/lib/utils' import { formatDate, formatRelativeDate } from '@/lib/utils'
import { TableOfContents } from '@/components/blog/table-of-contents'
import { ReadingProgress } from '@/components/blog/reading-progress'
import { StickyFooter } from '@/components/blog/sticky-footer'
import MarkdownRenderer from '@/components/blog/markdown-renderer'
export async function generateStaticParams() { export async function generateStaticParams() {
const posts = await getAllPosts() const posts = await getAllPosts()
@@ -39,41 +43,19 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
} }
} }
function AuthorInfo({ author, date }: { author: string; date: string }) { function extractHeadings(content: string) {
return ( const headingRegex = /^(#{2,3})\s+(.+)$/gm
<div className="flex items-center space-x-4 py-6 border-y border-gray-200 dark:border-gray-700"> const headings: { id: string; text: string; level: number }[] = []
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center"> let match
<span className="text-xl font-bold text-primary-600 dark:text-primary-400">
{author.charAt(0).toUpperCase()}
</span>
</div>
<div>
<p className="font-semibold">{author}</p>
<p className="text-sm text-gray-500">
Publicat {formatRelativeDate(date)} {formatDate(date)}
</p>
</div>
</div>
)
}
function RelatedPosts({ posts }: { posts: any[] }) { while ((match = headingRegex.exec(content)) !== null) {
if (posts.length === 0) return null const level = match[1].length
const text = match[2]
const id = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
headings.push({ id, text, level })
}
return ( return headings
<section className="mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
<h2 className="text-2xl font-bold mb-6">Articole similare</h2>
<div className="grid gap-6 md:grid-cols-3">
{posts.map((post) => (
<Link key={post.slug} href={`/blog/${post.slug}`} className="block p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-lg transition">
<h3 className="font-semibold mb-2 line-clamp-2">{post.frontmatter.title}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">{post.frontmatter.description}</p>
<p className="text-xs text-gray-500 mt-2">{formatDate(post.frontmatter.date)}</p>
</Link>
))}
</div>
</section>
)
} }
export default async function BlogPostPage({ params }: { params: Promise<{ slug: string[] }> }) { export default async function BlogPostPage({ params }: { params: Promise<{ slug: string[] }> }) {
@@ -86,44 +68,118 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
} }
const relatedPosts = await getRelatedPosts(slugPath) const relatedPosts = await getRelatedPosts(slugPath)
const headings = extractHeadings(post.content)
const fullUrl = `https://yourdomain.com/blog/${slugPath}`
return ( return (
<article className="max-w-4xl mx-auto"> <>
<header className="mb-8"> <ReadingProgress />
{post.frontmatter.image && (
<img src={post.frontmatter.image} alt={post.frontmatter.title} className="w-full h-64 md:h-96 object-cover rounded-lg mb-8" />
)}
<h1 className="text-4xl md:text-5xl font-bold mb-4">{post.frontmatter.title}</h1>
<p className="text-xl text-gray-600 dark:text-gray-400 mb-6">{post.frontmatter.description}</p>
{post.frontmatter.tags && post.frontmatter.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-6">
{post.frontmatter.tags.map((tag: string) => (
<Link key={tag} href={`/tags/${tag.toLowerCase().replace(/\s+/g, '-')}`} className="px-3 py-1 bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300 rounded-full text-sm hover:bg-primary-200 dark:hover:bg-primary-800 transition">
#{tag}
</Link>
))}
</div>
)}
<AuthorInfo author={post.frontmatter.author} date={post.frontmatter.date} />
</header>
<div className="prose dark:prose-invert max-w-none"> <div className="max-w-7xl mx-auto px-6 py-16">
<div className="flex items-center justify-between text-sm text-gray-500 mb-6"> <div className="flex gap-12">
<span>Timp estimat de citire: {post.readingTime} minute</span> <TableOfContents headings={headings} />
<article className="flex-1 min-w-0">
<header className="mb-16 border border-[var(--neon-cyan)] bg-[rgb(var(--bg-primary))] p-8 relative">
<div className="border-b border-[var(--neon-pink)] pb-4 mb-6 relative">
<div className="flex items-center gap-3 mb-3 justify-end">
<p className="font-mono text-xs text-[var(--neon-cyan)] uppercase tracking-widest">
&gt;&gt; CLASSIFIED_DOC://PUBLIC_ACCESS
</p>
<div className="flex gap-1.5">
<div className="w-4 h-4 border border-[rgb(var(--border-primary))] hover:bg-red-500/10 cursor-pointer" />
<div className="w-4 h-4 border border-[rgb(var(--border-primary))] hover:bg-yellow-500/10 cursor-pointer" />
<div className="w-4 h-4 border border-[rgb(var(--border-primary))] hover:bg-green-500/10 cursor-pointer" />
</div>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{post.frontmatter.tags.map((tag: string) => (
<span
key={tag}
className="px-3 py-1 bg-cyan-500/5 border border-[var(--neon-cyan)] text-cyan-400 text-xs font-mono uppercase shadow-[0_0_8px_rgba(90,139,149,0.3)] hover:shadow-[0_0_12px_rgba(90,139,149,0.5)] transition-all"
>
#{tag}
</span>
))}
</div>
</div>
<div className="border-l border-[var(--neon-magenta)] pl-6 relative">
<h1 className="text-4xl md:text-5xl font-mono font-bold text-[var(--neon-cyan)] uppercase tracking-tight leading-tight mb-6">
{post.frontmatter.title}
</h1>
<p className="text-lg text-[rgb(var(--text-secondary))] leading-relaxed mb-6 font-mono">
<span className="text-[var(--neon-pink)]">&gt;&gt;</span> {post.frontmatter.description}
</p>
</div>
<div className="flex items-center gap-4 pt-6 border-t border-[var(--neon-purple)] relative">
<div className="w-12 h-12 border border-[var(--neon-cyan)] bg-[rgb(var(--bg-secondary))] flex items-center justify-center">
<span className="font-mono text-[var(--neon-cyan)] text-xs">
{post.frontmatter.author.charAt(0).toUpperCase()}
</span>
</div>
<div>
<p className="font-mono font-bold text-[var(--neon-cyan)] uppercase text-sm">{post.frontmatter.author}</p>
<div className="flex items-center gap-2 text-xs text-[rgb(var(--text-muted))] font-mono">
<time className="text-[var(--neon-magenta)]">{formatDate(post.frontmatter.date)}</time>
<span className="text-[var(--neon-pink)]">//</span>
<span className="text-[var(--neon-cyan)]">{post.readingTime}min READ</span>
</div>
</div>
</div>
</header>
{post.frontmatter.image && (
<div className="relative aspect-video mb-16 border border-[var(--neon-pink)] overflow-hidden">
<img
src={post.frontmatter.image}
alt={post.frontmatter.title}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="prose prose-invert prose-lg max-w-none cyberpunk-prose">
<MarkdownRenderer content={post.content} />
</div>
{relatedPosts.length > 0 && (
<section className="mt-12 pt-8 border-t border-zinc-800">
<h2 className="text-2xl font-mono font-bold uppercase text-[var(--neon-cyan)] mb-6">// Articole similare</h2>
<div className="grid gap-6 md:grid-cols-3">
{relatedPosts.map((relatedPost) => (
<Link
key={relatedPost.slug}
href={`/blog/${relatedPost.slug}`}
className="block p-4 border border-zinc-800 bg-zinc-950 hover:border-[var(--neon-cyan)] transition-all hover:shadow-[0_0_8px_rgba(90,139,149,0.2)]"
>
<h3 className="font-mono font-semibold text-cyan-400 mb-2 line-clamp-2">{relatedPost.frontmatter.title}</h3>
<p className="text-sm text-zinc-400 line-clamp-2">{relatedPost.frontmatter.description}</p>
<p className="text-xs text-zinc-600 mt-2 font-mono">{formatDate(relatedPost.frontmatter.date)}</p>
</Link>
))}
</div>
</section>
)}
<nav className="flex justify-between items-center mt-12 pt-8 border-t border-zinc-800">
<Link
href="/blog"
className="flex items-center text-[var(--neon-pink)] hover:text-[var(--neon-magenta)] transition-all font-mono text-sm uppercase border border-[var(--neon-pink)] px-4 py-2 hover:shadow-[0_0_6px_rgba(155,90,110,0.3)]"
>
<svg className="mr-2 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
[BACK TO BLOG]
</Link>
</nav>
</article>
</div> </div>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</div> </div>
<nav className="flex justify-between items-center mt-12 pt-8 border-t border-gray-200 dark:border-gray-700"> <StickyFooter url={fullUrl} title={post.frontmatter.title} />
<Link href="/blog" className="flex items-center text-primary-600 hover:text-primary-700 transition"> </>
<svg className="mr-2 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Înapoi la blog
</Link>
</nav>
<RelatedPosts posts={relatedPosts} />
</article>
) )
} }

179
app/blog/blog-client.tsx Normal file
View File

@@ -0,0 +1,179 @@
'use client'
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[]
}
type SortOption = 'newest' | 'oldest' | 'title'
export default function BlogPageClient({ posts, allTags }: BlogPageClientProps) {
const [searchQuery, setSearchQuery] = useState('')
const [selectedTags, setSelectedTags] = useState<string[]>([])
const [sortBy, setSortBy] = useState<SortOption>('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
)
const toggleTag = (tag: string) => {
setSelectedTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
)
setCurrentPage(1)
}
return (
<div className="min-h-screen bg-[rgb(var(--bg-primary))]">
<div className="max-w-7xl mx-auto px-6 py-12">
{/* Header */}
<div className="border-l border-[var(--neon-cyan)] pl-6 mb-12">
<p className="font-mono text-xs text-[rgb(var(--text-muted))] uppercase tracking-widest mb-2">
DATABASE QUERY // SEARCH RESULTS
</p>
<h1 className="text-4xl md:text-6xl font-mono font-bold text-[rgb(var(--text-primary))] uppercase tracking-tight">
&gt; BLOG ARCHIVE_
</h1>
</div>
{/* Search Bar */}
<div className="border border-[rgb(var(--border-primary))] bg-[rgb(var(--bg-secondary))] p-6 mb-8">
<div className="flex flex-col lg:flex-row gap-4">
<SearchBar
searchQuery={searchQuery}
onSearchChange={(value) => {
setSearchQuery(value)
setCurrentPage(1)
}}
/>
<SortDropdown
sortBy={sortBy}
onSortChange={setSortBy}
/>
</div>
</div>
{/* Tag Filters */}
<TagFilter
allTags={allTags}
selectedTags={selectedTags}
onToggleTag={toggleTag}
onClearTags={() => {
setSelectedTags([])
setCurrentPage(1)
}}
/>
{/* Results Count */}
<div className="mb-6">
<p className="font-mono text-sm text-[rgb(var(--text-muted))] uppercase">
FOUND {filteredAndSortedPosts.length} {filteredAndSortedPosts.length === 1 ? 'POST' : 'POSTS'}
</p>
</div>
{/* Blog Grid */}
{paginatedPosts.length > 0 ? (
<div className="grid gap-8 lg:grid-cols-3 md:grid-cols-2 grid-cols-1 mb-12">
{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 <BlogCard key={post.slug} post={post} variant={variant} />
})}
</div>
) : (
<div className="border border-[rgb(var(--border-primary))] bg-[rgb(var(--bg-secondary))] p-12 text-center">
<p className="font-mono text-lg text-[rgb(var(--text-muted))] uppercase">
NO POSTS FOUND // TRY DIFFERENT SEARCH TERMS
</p>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="border border-[rgb(var(--border-primary))] bg-[rgb(var(--bg-secondary))] p-6">
<div className="flex items-center justify-between">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-6 py-3 font-mono text-sm uppercase border border-[rgb(var(--border-primary))] text-[rgb(var(--text-primary))] disabled:opacity-30 disabled:cursor-not-allowed hover:border-[var(--neon-cyan)] hover:text-[var(--neon-cyan)] transition-colors cursor-pointer"
>
&lt; PREV
</button>
<div className="flex items-center gap-2">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={`w-12 h-12 font-mono text-sm border transition-colors cursor-pointer ${
currentPage === page
? 'bg-[var(--neon-cyan)] border-[var(--neon-cyan)] text-white'
: 'border-[rgb(var(--border-primary))] text-[rgb(var(--text-muted))] hover:border-[var(--neon-cyan)] hover:text-[var(--neon-cyan)]'
}`}
>
{String(page).padStart(2, '0')}
</button>
))}
</div>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-6 py-3 font-mono text-sm uppercase border border-[rgb(var(--border-primary))] text-[rgb(var(--text-primary))] disabled:opacity-30 disabled:cursor-not-allowed hover:border-[var(--neon-cyan)] hover:text-[var(--neon-cyan)] transition-colors cursor-pointer"
>
NEXT &gt;
</button>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -1,15 +1,16 @@
import { Metadata } from 'next' import { Metadata } from 'next'
import { getAllPosts } from '@/lib/markdown' import { Navbar } from '@/components/blog/navbar'
import BlogPageClient from './page'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Blog', title: 'Blog',
description: 'Toate articolele din blog', description: 'Toate articolele din blog',
} }
export default async function BlogLayout() { export default function BlogLayout({ children }: { children: React.ReactNode }) {
const posts = await getAllPosts() return (
const allTags = Array.from(new Set(posts.flatMap((post) => post.frontmatter.tags))).sort() <>
<Navbar />
return <BlogPageClient posts={posts} allTags={allTags} /> {children}
</>
)
} }

View File

@@ -1,180 +1,9 @@
'use client' import { getAllPosts } from '@/lib/markdown'
import BlogPageClient from './blog-client'
import { useMemo, useState } from 'react' export default async function BlogPage() {
import { Post } from '@/lib/types/frontmatter' const posts = await getAllPosts()
import { BlogCard } from '@/components/blog/blog-card' const allTags = Array.from(new Set(posts.flatMap((post) => post.frontmatter.tags))).sort()
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 { return <BlogPageClient posts={posts} allTags={allTags} />
posts: Post[]
allTags: string[]
}
type SortOption = 'newest' | 'oldest' | 'title'
export default function BlogPageClient({ posts, allTags }: BlogPageClientProps) {
const [searchQuery, setSearchQuery] = useState('')
const [selectedTags, setSelectedTags] = useState<string[]>([])
const [sortBy, setSortBy] = useState<SortOption>('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
)
const toggleTag = (tag: string) => {
setSelectedTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
)
setCurrentPage(1)
}
return (
<div className="min-h-screen bg-zinc-900">
<Navbar />
<div className="max-w-7xl mx-auto px-6 py-12">
{/* Header */}
<div className="border-l-4 border-cyan-400 pl-6 mb-12">
<p className="font-mono text-xs text-zinc-500 uppercase tracking-widest mb-2">
DATABASE QUERY // SEARCH RESULTS
</p>
<h1 className="text-4xl md:text-6xl font-mono font-bold text-zinc-100 uppercase tracking-tight">
&gt; BLOG ARCHIVE_
</h1>
</div>
{/* Search Bar */}
<div className="border-4 border-slate-700 bg-slate-900 p-6 mb-8">
<div className="flex flex-col lg:flex-row gap-4">
<SearchBar
searchQuery={searchQuery}
onSearchChange={(value) => {
setSearchQuery(value)
setCurrentPage(1)
}}
/>
<SortDropdown
sortBy={sortBy}
onSortChange={setSortBy}
/>
</div>
</div>
{/* Tag Filters */}
<TagFilter
allTags={allTags}
selectedTags={selectedTags}
onToggleTag={toggleTag}
onClearTags={() => {
setSelectedTags([])
setCurrentPage(1)
}}
/>
{/* Results Count */}
<div className="mb-6">
<p className="font-mono text-sm text-zinc-500 uppercase">
FOUND {filteredAndSortedPosts.length} {filteredAndSortedPosts.length === 1 ? 'POST' : 'POSTS'}
</p>
</div>
{/* Blog Grid */}
{paginatedPosts.length > 0 ? (
<div className="grid gap-8 lg:grid-cols-3 md:grid-cols-2 grid-cols-1 mb-12">
{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 <BlogCard key={post.slug} post={post} variant={variant} />
})}
</div>
) : (
<div className="border-4 border-slate-700 bg-slate-900 p-12 text-center">
<p className="font-mono text-lg text-zinc-400 uppercase">
NO POSTS FOUND // TRY DIFFERENT SEARCH TERMS
</p>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="border-4 border-slate-700 bg-slate-900 p-6">
<div className="flex items-center justify-between">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-6 py-3 font-mono text-sm uppercase border-2 border-slate-700 text-zinc-100 disabled:opacity-30 disabled:cursor-not-allowed hover:border-cyan-400 hover:text-cyan-400 transition-colors cursor-pointer"
>
&lt; PREV
</button>
<div className="flex items-center gap-2">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={`w-12 h-12 font-mono text-sm border-2 transition-colors cursor-pointer ${
currentPage === page
? 'bg-cyan-400 border-cyan-400 text-slate-900'
: 'border-slate-700 text-zinc-400 hover:border-cyan-400 hover:text-cyan-400'
}`}
>
{String(page).padStart(2, '0')}
</button>
))}
</div>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-6 py-3 font-mono text-sm uppercase border-2 border-slate-700 text-zinc-100 disabled:opacity-30 disabled:cursor-not-allowed hover:border-cyan-400 hover:text-cyan-400 transition-colors cursor-pointer"
>
NEXT &gt;
</button>
</div>
</div>
)}
</div>
</div>
)
} }

View File

@@ -9,19 +9,20 @@
@layer base { @layer base {
:root { :root {
/* Light mode colors */ /* Light mode colors */
--bg-primary: 241 245 249; --bg-primary: 250 250 250;
--bg-secondary: 226 232 240; --bg-secondary: 240 240 243;
--bg-tertiary: 203 213 225; --bg-tertiary: 228 228 231;
--text-primary: 15 23 42; --text-primary: 24 24 27;
--text-secondary: 51 65 85; --text-secondary: 63 63 70;
--text-muted: 100 116 139; --text-muted: 113 113 122;
--border-primary: 203 213 225; --border-primary: 212 212 216;
--border-subtle: 226 232 240; --border-subtle: 228 228 231;
--neon-pink: #8b4a5e; /* Desaturated cyberpunk for light mode - darker for readability */
--neon-cyan: #4a7b85; --neon-pink: #7a3d52;
--neon-purple: #6b5583; --neon-cyan: #2d5a63;
--neon-magenta: #8b4a7e; --neon-purple: #5a4670;
--neon-magenta: #7a3d6b;
} }
.dark { .dark {
@@ -35,10 +36,11 @@
--border-primary: 71 85 105; --border-primary: 71 85 105;
--border-subtle: 30 41 59; --border-subtle: 30 41 59;
--neon-pink: #9b5a6e; /* Desaturated cyberpunk for dark mode */
--neon-cyan: #5a8b95; --neon-pink: #8a5568;
--neon-purple: #7b6593; --neon-cyan: #4d7580;
--neon-magenta: #9b5a8e; --neon-purple: #6a5685;
--neon-magenta: #8a5579;
} }
} }
@@ -322,5 +324,140 @@
inset 0 0 10px rgba(255, 0, 128, 0.1); inset 0 0 10px rgba(255, 0, 128, 0.1);
border-color: var(--neon-pink); border-color: var(--neon-pink);
} }
/* Cyberpunk Prose Styling */
.cyberpunk-prose {
color: rgb(212 212 216);
}
.cyberpunk-prose h1,
.cyberpunk-prose h2,
.cyberpunk-prose h3 {
color: var(--neon-cyan);
font-family: var(--font-jetbrains-mono);
font-weight: 700;
text-transform: uppercase;
letter-spacing: -0.025em;
}
.cyberpunk-prose h1 {
font-size: 2.25rem;
margin-bottom: 2rem;
margin-top: 3rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--neon-cyan);
}
.cyberpunk-prose h2 {
font-size: 1.875rem;
margin-bottom: 1.5rem;
margin-top: 2.5rem;
}
.cyberpunk-prose h3 {
font-size: 1.5rem;
margin-bottom: 1rem;
margin-top: 2rem;
}
.cyberpunk-prose p {
color: rgb(212 212 216);
line-height: 1.625;
margin-bottom: 1.5rem;
font-size: 1.125rem;
}
.cyberpunk-prose a {
color: var(--neon-magenta);
text-decoration: none;
font-weight: 600;
transition: color 0.2s;
}
.cyberpunk-prose a:hover {
color: var(--neon-pink);
}
.cyberpunk-prose ul,
.cyberpunk-prose ol {
color: rgb(212 212 216);
padding-left: 1.5rem;
margin-bottom: 1.5rem;
}
.cyberpunk-prose ul > * + *,
.cyberpunk-prose ol > * + * {
margin-top: 0.5rem;
}
.cyberpunk-prose li {
font-size: 1.125rem;
}
.cyberpunk-prose blockquote {
border-left: 4px solid var(--neon-magenta);
padding-left: 1.5rem;
font-style: italic;
color: rgb(161 161 170);
background-color: #000;
padding-top: 1.5rem;
padding-bottom: 1.5rem;
margin-top: 2rem;
margin-bottom: 2rem;
position: relative;
box-shadow: -4px 0 15px rgba(155,90,142,0.3), inset 0 0 20px rgba(155,90,142,0.05);
}
.cyberpunk-prose blockquote::before {
content: '"';
position: absolute;
top: -0.5rem;
left: 0.5rem;
font-size: 3.75rem;
color: var(--neon-magenta);
opacity: 0.3;
font-family: monospace;
}
.cyberpunk-prose code {
color: var(--neon-cyan);
background-color: #000;
padding: 0.125rem 0.5rem;
font-size: 0.875rem;
font-family: monospace;
border: 2px solid var(--neon-cyan);
box-shadow: 0 0 8px rgba(90,139,149,0.3);
text-shadow: 0 0 6px rgba(90,139,149,0.6);
}
.cyberpunk-prose pre {
background-color: #000;
border: 4px solid var(--neon-purple);
padding: 1.5rem;
margin-top: 2rem;
margin-bottom: 2rem;
overflow-x: auto;
box-shadow: 0 0 25px rgba(123,101,147,0.6), inset 0 0 25px rgba(123,101,147,0.1);
}
.cyberpunk-prose pre code {
background-color: transparent;
border: 0;
padding: 0;
}
.cyberpunk-prose img {
margin-top: 2rem;
margin-bottom: 2rem;
border: 4px solid var(--neon-pink);
box-shadow: 0 0 20px rgba(155,90,110,0.5);
}
.cyberpunk-prose hr {
border-color: rgb(39 39 42);
border-top-width: 2px;
margin-top: 3rem;
margin-bottom: 3rem;
}
} }

View File

@@ -14,8 +14,8 @@ export function BlogCard({ post, variant }: BlogCardProps) {
if (!hasImage || variant === 'text-only') { if (!hasImage || variant === 'text-only') {
return ( return (
<Link href={`/blog/${post.slug}`} className="block cursor-pointer"> <Link href={`/blog/${post.slug}`} className="block cursor-pointer">
<article className="border-4 border-slate-700 bg-slate-900 p-6 h-full cyber-glitch-hover"> <article className="border border-slate-700 bg-slate-900 p-6 h-full cyber-glitch-hover">
<div className="border-l-4 pl-4 mb-4" style={{ borderColor: 'var(--neon-pink)' }}> <div className="border-l-2 pl-4 mb-4" style={{ borderColor: 'var(--neon-pink)' }}>
<span className="font-mono text-xs text-zinc-100 uppercase tracking-wider"> <span className="font-mono text-xs text-zinc-100 uppercase tracking-wider">
{post.frontmatter.category} <span style={{ color: 'var(--neon-cyan)' }}>//</span> {formatDate(post.frontmatter.date)} {post.frontmatter.category} <span style={{ color: 'var(--neon-cyan)' }}>//</span> {formatDate(post.frontmatter.date)}
</span> </span>
@@ -44,7 +44,7 @@ export function BlogCard({ post, variant }: BlogCardProps) {
if (variant === 'image-side') { if (variant === 'image-side') {
return ( return (
<Link href={`/blog/${post.slug}`} className="block cursor-pointer"> <Link href={`/blog/${post.slug}`} className="block cursor-pointer">
<article className="border-4 border-slate-700 bg-slate-900 overflow-hidden h-full cyber-glitch-hover"> <article className="border border-slate-700 bg-slate-900 overflow-hidden h-full cyber-glitch-hover">
<div className="flex flex-col md:flex-row h-full"> <div className="flex flex-col md:flex-row h-full">
<div className="md:w-1/3 relative h-64 md:h-auto bg-zinc-900"> <div className="md:w-1/3 relative h-64 md:h-auto bg-zinc-900">
<Image <Image
@@ -56,7 +56,7 @@ export function BlogCard({ post, variant }: BlogCardProps) {
<div className="absolute inset-0 bg-zinc-900/60" /> <div className="absolute inset-0 bg-zinc-900/60" />
</div> </div>
<div className="md:w-2/3 p-6"> <div className="md:w-2/3 p-6">
<div className="border-l-4 border-cyan-400 pl-4 mb-4"> <div className="border-l-2 border-cyan-400 pl-4 mb-4">
<span className="font-mono text-xs text-zinc-100 uppercase tracking-wider"> <span className="font-mono text-xs text-zinc-100 uppercase tracking-wider">
{post.frontmatter.category} // {formatDate(post.frontmatter.date)} {post.frontmatter.category} // {formatDate(post.frontmatter.date)}
</span> </span>
@@ -86,7 +86,7 @@ export function BlogCard({ post, variant }: BlogCardProps) {
return ( return (
<Link href={`/blog/${post.slug}`} className="block cursor-pointer"> <Link href={`/blog/${post.slug}`} className="block cursor-pointer">
<article className="border-4 border-slate-700 bg-slate-900 overflow-hidden transition-all duration-300 cyber-glitch-hover h-full"> <article className="border border-slate-700 bg-slate-900 overflow-hidden transition-all duration-300 cyber-glitch-hover h-full">
<div className="relative h-64 bg-zinc-900"> <div className="relative h-64 bg-zinc-900">
<Image <Image
src={post.frontmatter.image!} src={post.frontmatter.image!}
@@ -97,7 +97,7 @@ export function BlogCard({ post, variant }: BlogCardProps) {
<div className="absolute inset-0 bg-zinc-900/60" /> <div className="absolute inset-0 bg-zinc-900/60" />
</div> </div>
<div className="p-6"> <div className="p-6">
<div className="border-l-4 pl-4 mb-4" style={{ borderColor: 'var(--neon-pink)' }}> <div className="border-l-2 pl-4 mb-4" style={{ borderColor: 'var(--neon-pink)' }}>
<span className="font-mono text-xs text-zinc-100 uppercase tracking-wider"> <span className="font-mono text-xs text-zinc-100 uppercase tracking-wider">
{post.frontmatter.category} <span style={{ color: 'var(--neon-cyan)' }}>//</span> {formatDate(post.frontmatter.date)} {post.frontmatter.category} <span style={{ color: 'var(--neon-cyan)' }}>//</span> {formatDate(post.frontmatter.date)}
</span> </span>

View File

@@ -0,0 +1,55 @@
'use client'
import { useState } from 'react'
interface CodeBlockProps {
code: string
language: string
filename?: string
showLineNumbers?: boolean
}
export function CodeBlock({ code, language, filename, showLineNumbers = true }: CodeBlockProps) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="not-prose my-8 border-2 border-[var(--neon-purple)] bg-[rgb(var(--bg-primary))] dark:bg-black relative overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 bg-[rgb(var(--bg-secondary))] dark:bg-zinc-950 border-b-2 border-[var(--neon-purple)] relative">
<div className="flex items-center gap-3">
{filename && (
<span className="text-[var(--neon-cyan)] font-mono text-sm uppercase">&gt;&gt; {filename}</span>
)}
<span className="px-2 py-1 border border-[var(--neon-purple)] text-[var(--neon-purple)] text-xs font-mono uppercase">
[{language}]
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
className="px-3 py-1 bg-[rgb(var(--bg-primary))] dark:bg-black hover:bg-purple-900/30 border border-[var(--neon-purple)] text-[var(--neon-purple)] text-xs font-mono uppercase transition-all"
>
{copied ? '[COPIED✓]' : '[COPY]'}
</button>
<div className="flex gap-1">
<div className="w-3 h-3 border border-[rgb(var(--border-primary))]" />
<div className="w-3 h-3 border border-[rgb(var(--border-primary))]" />
<div className="w-3 h-3 border border-[rgb(var(--border-primary))]" />
</div>
</div>
</div>
<div className="relative overflow-x-auto">
<pre className="p-6 text-sm leading-relaxed bg-[rgb(var(--bg-primary))] dark:bg-black text-[var(--neon-cyan)] font-mono">
<code>{code}</code>
</pre>
</div>
</div>
)
}

View File

@@ -4,8 +4,7 @@ import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { CodeBlock } from './code-block';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
interface MarkdownRendererProps { interface MarkdownRendererProps {
content: string; content: string;
@@ -16,55 +15,33 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={{
h1: ({ children }) => ( h1: ({ children }) => {
<h1 className="text-4xl font-bold mt-8 mb-4">{children}</h1> const text = String(children);
), const id = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
h2: ({ children }) => ( return <h1 id={id}>{children}</h1>;
<h2 className="text-3xl font-bold mt-6 mb-3">{children}</h2> },
), h2: ({ children }) => {
h3: ({ children }) => ( const text = String(children);
<h3 className="text-2xl font-bold mt-4 mb-2">{children}</h3> const id = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
), return <h2 id={id}>{children}</h2>;
h4: ({ children }) => ( },
<h4 className="text-xl font-bold mt-3 mb-2">{children}</h4> h3: ({ children }) => {
), const text = String(children);
h5: ({ children }) => ( const id = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
<h5 className="text-lg font-bold mt-2 mb-1">{children}</h5> return <h3 id={id}>{children}</h3>;
), },
h6: ({ children }) => (
<h6 className="text-base font-bold mt-2 mb-1">{children}</h6>
),
p: ({ children }) => (
<p className="my-4 leading-7">{children}</p>
),
ul: ({ children }) => (
<ul className="list-disc list-inside my-4 space-y-2">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside my-4 space-y-2">{children}</ol>
),
li: ({ children }) => (
<li className="ml-4">{children}</li>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-gray-300 pl-4 my-4 italic text-gray-700">
{children}
</blockquote>
),
code: ({ inline, className, children, ...props }: any) => { code: ({ inline, className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || ''); const match = /language-(\w+)/.exec(className || '');
return !inline && match ? ( if (!inline && match) {
<SyntaxHighlighter return (
style={vscDarkPlus} <CodeBlock
language={match[1]} code={String(children).replace(/\n$/, '')}
PreTag="div" language={match[1]}
className="my-4 rounded-lg" />
{...props} );
> }
{String(children).replace(/\n$/, '')} return (
</SyntaxHighlighter> <code {...props}>
) : (
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm font-mono" {...props}>
{children} {children}
</code> </code>
); );
@@ -78,19 +55,18 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
<img <img
src={src} src={src}
alt={alt || ''} alt={alt || ''}
className="my-4 rounded-lg max-w-full h-auto" className="w-full h-auto"
/> />
); );
} }
return ( return (
<div className="my-4 relative w-full h-auto"> <div className="relative w-full h-auto">
<Image <Image
src={src} src={src}
alt={alt || ''} alt={alt || ''}
width={800} width={800}
height={600} height={600}
className="rounded-lg"
style={{ width: '100%', height: 'auto' }} style={{ width: '100%', height: 'auto' }}
/> />
</div> </div>
@@ -106,7 +82,6 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
href={href} href={href}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline"
> >
{children} {children}
</a> </a>
@@ -114,35 +89,11 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
} }
return ( return (
<Link href={href} className="text-blue-600 hover:underline"> <Link href={href}>
{children} {children}
</Link> </Link>
); );
}, },
table: ({ children }) => (
<div className="overflow-x-auto my-4">
<table className="min-w-full border-collapse border border-gray-300">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-gray-100">{children}</thead>
),
tbody: ({ children }) => (
<tbody>{children}</tbody>
),
tr: ({ children }) => (
<tr className="border-b border-gray-300">{children}</tr>
),
th: ({ children }) => (
<th className="border border-gray-300 px-4 py-2 text-left font-bold">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-gray-300 px-4 py-2">{children}</td>
),
}} }}
> >
{content} {content}

View File

@@ -0,0 +1,40 @@
'use client'
import { useEffect, useState } from 'react'
export function ReadingProgress() {
const [progress, setProgress] = useState(0)
useEffect(() => {
const updateProgress = () => {
const scrollTop = window.scrollY
const docHeight = document.documentElement.scrollHeight - window.innerHeight
const scrollPercent = (scrollTop / docHeight) * 100
setProgress(Math.min(scrollPercent, 100))
}
window.addEventListener('scroll', updateProgress, { passive: true })
updateProgress()
return () => window.removeEventListener('scroll', updateProgress)
}, [])
return (
<>
<div className="fixed top-0 left-0 right-0 h-1.5 bg-[rgb(var(--bg-secondary))] dark:bg-black z-50 border-b-2 border-[rgb(var(--border-primary))]">
<div
className="h-full bg-gradient-to-r from-[var(--neon-cyan)] via-[var(--neon-magenta)] to-[var(--neon-pink)] transition-all duration-150"
style={{
width: `${progress}%`,
boxShadow: progress > 0 ? '0 0 8px var(--neon-cyan)' : 'none'
}}
/>
</div>
<div className="fixed top-4 right-4 z-50 px-3 py-1.5 bg-[rgb(var(--bg-primary))] border-2 border-[var(--neon-cyan)] text-xs font-mono font-bold text-[var(--neon-cyan)] relative">
<span className="relative z-10">
[{Math.round(progress)}%]
</span>
</div>
</>
)
}

View File

@@ -5,7 +5,7 @@ interface SearchBarProps {
export function SearchBar({ searchQuery, onSearchChange }: SearchBarProps) { export function SearchBar({ searchQuery, onSearchChange }: SearchBarProps) {
return ( return (
<div className="flex-1 flex items-center border-2 border-slate-700 bg-zinc-900 transition-all focus-within:border-[var(--neon-cyan)] focus-within: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)]"> <div className="flex-1 flex items-center border border-slate-700 bg-zinc-900 transition-all focus-within:border-[var(--neon-cyan)] focus-within:shadow-[0_0_6px_rgba(0,255,255,0.4),inset_0_0_6px_rgba(0,255,255,0.05)]">
<span className="pl-4 pr-2 font-mono text-lg" style={{ color: 'var(--neon-cyan)' }}>&gt;</span> <span className="pl-4 pr-2 font-mono text-lg" style={{ color: 'var(--neon-cyan)' }}>&gt;</span>
<input <input
type="text" type="text"

View File

@@ -0,0 +1,109 @@
'use client'
import { useEffect, useState } from 'react'
interface StickyFooterProps {
url: string
title: string
}
export function StickyFooter({ url, title }: StickyFooterProps) {
const [isVisible, setIsVisible] = useState(true)
const [lastScrollY, setLastScrollY] = useState(0)
const [copied, setCopied] = useState(false)
useEffect(() => {
const handleScroll = () => {
const currentScrollY = window.scrollY
setIsVisible(currentScrollY < lastScrollY || currentScrollY < 100)
setLastScrollY(currentScrollY)
}
window.addEventListener('scroll', handleScroll, { passive: true })
return () => window.removeEventListener('scroll', handleScroll)
}, [lastScrollY])
const shareLinks = {
twitter: `https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${url}`,
linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${url}`,
}
const handleCopyLink = async () => {
await navigator.clipboard.writeText(url)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
return (
<footer
className={`
fixed bottom-0 left-0 right-0 z-40
bg-black/98 backdrop-blur-sm
border-t-4 border-[var(--neon-magenta)]
transition-transform duration-200 ease-in-out
${isVisible ? 'translate-y-0' : 'translate-y-full'}
`}
style={{
boxShadow: isVisible ? '0 -8px 30px rgba(155,90,142,0.5), inset 0 4px 20px rgba(155,90,142,0.1)' : 'none'
}}
>
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-[var(--neon-magenta)] to-transparent opacity-70" />
<div className="max-w-7xl mx-auto px-6 py-4 relative">
<div className="flex items-center justify-between">
<div className="hidden md:flex items-center gap-3">
<div className="flex gap-1.5">
<div className="w-2 h-2 bg-[var(--neon-cyan)] shadow-[0_0_6px_rgba(90,139,149,1)]" />
<div className="w-2 h-2 bg-[var(--neon-pink)] shadow-[0_0_6px_rgba(155,90,110,1)]" />
</div>
<span className="text-[var(--neon-cyan)] font-mono text-xs uppercase tracking-wider" style={{ textShadow: '0 0 8px rgba(90,139,149,0.6)' }}>
&gt;&gt; SHARE:
</span>
</div>
<div className="flex items-center gap-3 mx-auto md:mx-0">
<a
href={shareLinks.twitter}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 bg-black border-4 border-cyan-400 text-cyan-400 font-mono text-xs uppercase tracking-wider transition-all hover:shadow-[0_0_25px_rgba(29,161,242,0.8)] hover:bg-cyan-900/20"
style={{ textShadow: '0 0 8px rgba(56,189,248,0.6)' }}
>
[X]
</a>
<a
href={shareLinks.linkedin}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 bg-black border-4 border-blue-400 text-blue-400 font-mono text-xs uppercase tracking-wider transition-all hover:shadow-[0_0_25px_rgba(10,102,194,0.8)] hover:bg-blue-900/20"
style={{ textShadow: '0 0 8px rgba(96,165,250,0.6)' }}
>
[IN]
</a>
<button
onClick={handleCopyLink}
className="flex items-center gap-2 px-4 py-2 bg-black border-4 border-[var(--neon-pink)] text-[var(--neon-pink)] font-mono text-xs uppercase tracking-wider transition-all hover:shadow-[0_0_25px_rgba(155,90,110,0.8)] hover:bg-pink-900/20"
style={{ textShadow: copied ? '0 0 10px rgba(155,90,110,1)' : '0 0 8px rgba(155,90,110,0.6)' }}
>
{copied ? '[✓ COPIED]' : '[COPY]'}
</button>
</div>
<button
onClick={scrollToTop}
className="hidden md:flex items-center gap-2 px-4 py-2 bg-black border-4 border-[var(--neon-cyan)] text-[var(--neon-cyan)] font-mono text-xs uppercase tracking-wider transition-all hover:shadow-[0_0_25px_rgba(90,139,149,0.8)] hover:bg-cyan-900/20"
style={{ textShadow: '0 0 8px rgba(90,139,149,0.6)' }}
>
[ TOP]
</button>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,79 @@
'use client'
import { useEffect, useState } from 'react'
interface Heading {
id: string
text: string
level: number
}
interface TOCProps {
headings: Heading[]
}
export function TableOfContents({ headings }: TOCProps) {
const [activeId, setActiveId] = useState('')
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id)
}
})
},
{ rootMargin: '-100px 0px -66%' }
)
headings.forEach(({ id }) => {
const element = document.getElementById(id)
if (element) observer.observe(element)
})
return () => observer.disconnect()
}, [headings])
return (
<aside className="hidden lg:block sticky top-24 w-64 h-fit">
<div className="bg-black border border-[var(--neon-cyan)] p-6 relative overflow-hidden shadow-[0_0_15px_rgba(90,139,149,0.3),inset_0_0_15px_rgba(90,139,149,0.05)]">
<div className="absolute inset-0 bg-gradient-to-br from-cyan-500/5 via-magenta-500/3 to-transparent pointer-events-none" />
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-transparent via-[var(--neon-cyan)] to-transparent opacity-50" />
<div className="border-b border-[var(--neon-magenta)] pb-3 mb-4 relative">
<div className="flex gap-1.5 mb-2 justify-end">
<div className="w-3 h-3 border border-[var(--neon-cyan)]/40 hover:bg-[var(--neon-cyan)]/10 transition-colors cursor-pointer" title="Minimize" />
<div className="w-3 h-3 border border-[var(--neon-cyan)]/40 hover:bg-[var(--neon-cyan)]/10 transition-colors cursor-pointer" title="Maximize" />
<div className="w-3 h-3 border border-[var(--neon-pink)]/40 hover:bg-[var(--neon-pink)]/10 transition-colors cursor-pointer" title="Close" />
</div>
<h3 className="text-xs font-mono font-bold text-[var(--neon-cyan)] uppercase tracking-wider" style={{ textShadow: '0 0 6px rgba(90,139,149,0.5)' }}>
&gt;&gt; NAVIGATION
</h3>
</div>
<nav className="space-y-1 relative">
{headings.map((heading) => (
<a
key={heading.id}
href={`#${heading.id}`}
className={`
block text-sm font-mono py-2 border-l-2 transition-all duration-150
${heading.level === 2 ? 'pl-3' : 'pl-6'}
${activeId === heading.id
? 'text-[var(--neon-cyan)] border-[var(--neon-cyan)] bg-cyan-500/5 shadow-[0_0_8px_rgba(90,139,149,0.3)]'
: 'text-zinc-500 border-zinc-900 hover:border-[var(--neon-magenta)] hover:text-[var(--neon-magenta)] hover:bg-magenta-500/3 hover:shadow-[0_0_4px_rgba(155,90,142,0.2)]'
}
`}
style={activeId === heading.id ? { textShadow: '0 0 4px rgba(90,139,149,0.5)' } : {}}
>
<span className="inline-block">{activeId === heading.id ? '▶ ' : '◆ '}</span>{heading.text}
</a>
))}
</nav>
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-gradient-to-r from-transparent via-[var(--neon-purple)] to-transparent opacity-40" />
</div>
</aside>
)
}

View File

@@ -9,7 +9,7 @@ export function TagFilter({ allTags, selectedTags, onToggleTag, onClearTags }: T
if (allTags.length === 0) return null if (allTags.length === 0) return null
return ( return (
<div className="border-4 border-slate-700 bg-slate-900 p-6 mb-12"> <div className="border border-slate-700 bg-slate-900 p-6 mb-12">
<p className="font-mono text-xs text-zinc-500 uppercase tracking-widest mb-4"> <p className="font-mono text-xs text-zinc-500 uppercase tracking-widest mb-4">
FILTER BY TAG FILTER BY TAG
</p> </p>
@@ -18,7 +18,7 @@ export function TagFilter({ allTags, selectedTags, onToggleTag, onClearTags }: T
<button <button
key={tag} key={tag}
onClick={() => onToggleTag(tag)} onClick={() => onToggleTag(tag)}
className={`px-4 py-2 font-mono text-xs uppercase border-2 transition-colors cursor-pointer ${ className={`px-4 py-2 font-mono text-xs uppercase border transition-colors cursor-pointer ${
selectedTags.includes(tag) selectedTags.includes(tag)
? 'bg-cyan-400 border-cyan-400 text-slate-900' ? 'bg-cyan-400 border-cyan-400 text-slate-900'
: 'bg-zinc-900 border-slate-700 text-zinc-400 hover:border-cyan-400 hover:text-cyan-400' : 'bg-zinc-900 border-slate-700 text-zinc-400 hover:border-cyan-400 hover:text-cyan-400'

View File

@@ -53,7 +53,7 @@ export function validateFrontmatter(data: any): FrontMatter {
} }
export function getPostBySlug(slug: string | string[]): Post | null { export function getPostBySlug(slug: string | string[]): Post | null {
const slugArray = Array.isArray(slug) ? slug : [slug]; const slugArray = Array.isArray(slug) ? slug : slug.split('/');
const sanitized = slugArray.map(s => sanitizePath(s)); const sanitized = slugArray.map(s => sanitizePath(s));
const fullPath = path.join(POSTS_PATH, ...sanitized) + '.md'; const fullPath = path.join(POSTS_PATH, ...sanitized) + '.md';