7 Commits

Author SHA1 Message Date
dd5f13e29b Merge pull request '💂‍♂️ Added lint and prettier' (#4) from feat/lintprittier into master
Reviewed-on: http://192.168.1.53:3000/raresj/mypage/pulls/4
2025-11-14 13:34:24 +00:00
RJ
820a2b88d5 💂‍♂️ fixed lint and prittier 2025-11-14 15:33:00 +02:00
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
44 changed files with 7501 additions and 681 deletions

View File

@@ -0,0 +1,774 @@
---
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.

9
.prettierignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
.next
out
build
dist
.cache
package-lock.json
public
coverage

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 100,
"arrowParens": "avoid",
"trailingComma": "es5"
}

347
CLAUDE.md Normal file
View File

@@ -0,0 +1,347 @@
# 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() {
return (
@@ -11,5 +11,5 @@ export default function AboutBreadcrumb() {
},
]}
/>
);
)
}

View File

@@ -1,10 +1,10 @@
import { Breadcrumbs } from '@/components/layout/breadcrumbs';
import { getPostBySlug } from '@/lib/markdown';
import { Breadcrumbs } from '@/components/layout/Breadcrumbs'
import { getPostBySlug } from '@/lib/markdown'
interface BreadcrumbItem {
label: string;
href: string;
current?: boolean;
label: string
href: string
current?: boolean
}
function formatDirectoryName(name: string): string {
@@ -12,34 +12,34 @@ function formatDirectoryName(name: string): string {
tech: 'Tehnologie',
design: 'Design',
tutorial: 'Tutoriale',
};
}
return directoryNames[name] || name.charAt(0).toUpperCase() + name.slice(1);
return directoryNames[name] || name.charAt(0).toUpperCase() + name.slice(1)
}
export default async function BlogPostBreadcrumb({
params,
}: {
params: Promise<{ slug: string[] }>;
params: Promise<{ slug: string[] }>
}) {
const { slug } = await params;
const slugPath = slug.join('/');
const post = getPostBySlug(slugPath);
const { slug } = await params
const slugPath = slug.join('/')
const post = getPostBySlug(slugPath)
const items: BreadcrumbItem[] = [
{
label: 'Blog',
href: '/blog',
},
];
]
if (slug.length > 1) {
for (let i = 0; i < slug.length - 1; i++) {
const segmentPath = slug.slice(0, i + 1).join('/');
const segmentPath = slug.slice(0, i + 1).join('/')
items.push({
label: formatDirectoryName(slug[i]),
href: `/blog/${segmentPath}`,
});
})
}
}
@@ -47,7 +47,7 @@ export default async function BlogPostBreadcrumb({
label: post ? post.frontmatter.title : slug[slug.length - 1],
href: `/blog/${slugPath}`,
current: true,
});
})
return <Breadcrumbs items={items} />;
return <Breadcrumbs items={items} />
}

View File

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

View File

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

View File

@@ -1,15 +1,11 @@
import { Breadcrumbs } from '@/components/layout/breadcrumbs';
import { Breadcrumbs } from '@/components/layout/Breadcrumbs'
export default async function TagBreadcrumb({
params,
}: {
params: Promise<{ tag: string }>;
}) {
const { tag } = await params;
export default async function TagBreadcrumb({ params }: { params: Promise<{ tag: string }> }) {
const { tag } = await params
const tagName = tag
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
.join(' ')
return (
<Breadcrumbs
@@ -25,5 +21,5 @@ export default async function TagBreadcrumb({
},
]}
/>
);
)
}

View File

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

View File

@@ -12,8 +12,8 @@ export default function AboutPage() {
<div className="prose dark:prose-invert max-w-none">
<p className="text-lg leading-relaxed mb-6">
Bun venit pe blogul meu! Sunt un dezvoltator pasionat de tehnologie,
specializat în dezvoltarea web modernă cu Next.js, React și TypeScript.
Bun venit pe blogul meu! Sunt un dezvoltator pasionat de tehnologie, specializat în
dezvoltarea web modernă cu Next.js, React și TypeScript.
</p>
<h2 className="text-2xl font-semibold mt-8 mb-4">Ce vei găsi aici</h2>
@@ -27,10 +27,18 @@ export default function AboutPage() {
<h2 className="text-2xl font-semibold mt-8 mb-4">Tehnologii folosite</h2>
<p>Acest blog este construit cu:</p>
<ul className="space-y-2">
<li><strong>Next.js 15</strong> - Framework React pentru producție</li>
<li><strong>TypeScript</strong> - Pentru type safety</li>
<li><strong>Tailwind CSS</strong> - Pentru stilizare rapidă</li>
<li><strong>Markdown</strong> - Pentru conținut</li>
<li>
<strong>Next.js 15</strong> - Framework React pentru producție
</li>
<li>
<strong>TypeScript</strong> - Pentru type safety
</li>
<li>
<strong>Tailwind CSS</strong> - Pentru stilizare rapidă
</li>
<li>
<strong>Markdown</strong> - Pentru conținut
</li>
</ul>
<h2 className="text-2xl font-semibold mt-8 mb-4">Contact</h2>

View File

@@ -10,10 +10,16 @@ export default function NotFound() {
Ne pare rău, dar articolul pe care îl cauți nu există sau a fost mutat.
</p>
<div className="space-x-4">
<Link href="/blog" className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition">
<Link
href="/blog"
className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition"
>
Vezi toate articolele
</Link>
<Link href="/" className="inline-block px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition">
<Link
href="/"
className="inline-block px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition"
>
Pagina principală
</Link>
</div>

View File

@@ -3,13 +3,21 @@ import { notFound } from 'next/navigation'
import Link from 'next/link'
import { getAllPosts, getPostBySlug, getRelatedPosts } from '@/lib/markdown'
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() {
const posts = await getAllPosts()
return posts.map((post) => ({ slug: post.slug.split('/') }))
return posts.map(post => ({ slug: post.slug.split('/') }))
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string[] }> }): Promise<Metadata> {
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string[] }>
}): Promise<Metadata> {
const { slug } = await params
const slugPath = slug.join('/')
const post = getPostBySlug(slugPath)
@@ -39,41 +47,22 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
}
}
function AuthorInfo({ author, date }: { author: string; date: string }) {
return (
<div className="flex items-center space-x-4 py-6 border-y border-gray-200 dark:border-gray-700">
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
<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 extractHeadings(content: string) {
const headingRegex = /^(#{2,3})\s+(.+)$/gm
const headings: { id: string; text: string; level: number }[] = []
let match
while ((match = headingRegex.exec(content)) !== 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 })
}
function RelatedPosts({ posts }: { posts: any[] }) {
if (posts.length === 0) return null
return (
<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>
)
return headings
}
export default async function BlogPostPage({ params }: { params: Promise<{ slug: string[] }> }) {
@@ -86,44 +75,136 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
}
const relatedPosts = await getRelatedPosts(slugPath)
const headings = extractHeadings(post.content)
const fullUrl = `https://yourdomain.com/blog/${slugPath}`
return (
<article className="max-w-4xl mx-auto">
<header className="mb-8">
{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">
<>
<ReadingProgress />
<div className="max-w-7xl mx-auto px-6 py-16">
<div className="flex gap-12">
<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) => (
<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">
<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>
)}
<AuthorInfo author={post.frontmatter.author} date={post.frontmatter.date} />
</header>
<div className="prose dark:prose-invert max-w-none">
<div className="flex items-center justify-between text-sm text-gray-500 mb-6">
<span>Timp estimat de citire: {post.readingTime} minute</span>
</div>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</div>
<nav className="flex justify-between items-center mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
<Link href="/blog" className="flex items-center text-primary-600 hover:text-primary-700 transition">
<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" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
Înapoi la blog
[BACK TO BLOG]
</Link>
</nav>
<RelatedPosts posts={relatedPosts} />
</article>
</div>
</div>
<StickyFooter url={fullUrl} title={post.frontmatter.title} />
</>
)
}

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

@@ -0,0 +1,173 @@
'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(() => {
const 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 { getAllPosts } from '@/lib/markdown'
import BlogPageClient from './page'
import { Navbar } from '@/components/blog/navbar'
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 <BlogPageClient posts={posts} allTags={allTags} />
export default function BlogLayout({ children }: { children: React.ReactNode }) {
return (
<>
<Navbar />
{children}
</>
)
}

View File

@@ -1,180 +1,9 @@
'use client'
import { getAllPosts } from '@/lib/markdown'
import BlogPageClient from './blog-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'
export default async function BlogPage() {
const posts = await getAllPosts()
const allTags = Array.from(new Set(posts.flatMap(post => post.frontmatter.tags))).sort()
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-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>
)
return <BlogPageClient posts={posts} allTags={allTags} />
}

View File

@@ -1,4 +1,4 @@
@import "tailwindcss";
@import 'tailwindcss';
@theme {
--color-*: initial;
@@ -9,19 +9,20 @@
@layer base {
:root {
/* Light mode colors */
--bg-primary: 241 245 249;
--bg-secondary: 226 232 240;
--bg-tertiary: 203 213 225;
--text-primary: 15 23 42;
--text-secondary: 51 65 85;
--text-muted: 100 116 139;
--border-primary: 203 213 225;
--border-subtle: 226 232 240;
--bg-primary: 250 250 250;
--bg-secondary: 240 240 243;
--bg-tertiary: 228 228 231;
--text-primary: 24 24 27;
--text-secondary: 63 63 70;
--text-muted: 113 113 122;
--border-primary: 212 212 216;
--border-subtle: 228 228 231;
--neon-pink: #8b4a5e;
--neon-cyan: #4a7b85;
--neon-purple: #6b5583;
--neon-magenta: #8b4a7e;
/* Desaturated cyberpunk for light mode - darker for readability */
--neon-pink: #7a3d52;
--neon-cyan: #2d5a63;
--neon-purple: #5a4670;
--neon-magenta: #7a3d6b;
}
.dark {
@@ -35,10 +36,11 @@
--border-primary: 71 85 105;
--border-subtle: 30 41 59;
--neon-pink: #9b5a6e;
--neon-cyan: #5a8b95;
--neon-purple: #7b6593;
--neon-magenta: #9b5a8e;
/* Desaturated cyberpunk for dark mode */
--neon-pink: #8a5568;
--neon-cyan: #4d7580;
--neon-purple: #6a5685;
--neon-magenta: #8a5579;
}
}
@@ -68,12 +70,7 @@
/* Scanline effect */
.scanline {
background: linear-gradient(
0deg,
transparent 0%,
rgba(6, 182, 212, 0.1) 50%,
transparent 100%
);
background: linear-gradient(0deg, transparent 0%, rgba(6, 182, 212, 0.1) 50%, transparent 100%);
background-size: 100% 3px;
pointer-events: none;
}
@@ -133,19 +130,43 @@
}
@keyframes glitch-1 {
0%, 100% { clip-path: polygon(0 0, 100% 0, 100% 45%, 0 45%); transform: translate(0); }
20% { clip-path: polygon(0 15%, 100% 15%, 100% 65%, 0 65%); }
40% { clip-path: polygon(0 30%, 100% 30%, 100% 70%, 0 70%); }
60% { clip-path: polygon(0 5%, 100% 5%, 100% 60%, 0 60%); }
80% { clip-path: polygon(0 25%, 100% 25%, 100% 40%, 0 40%); }
0%,
100% {
clip-path: polygon(0 0, 100% 0, 100% 45%, 0 45%);
transform: translate(0);
}
20% {
clip-path: polygon(0 15%, 100% 15%, 100% 65%, 0 65%);
}
40% {
clip-path: polygon(0 30%, 100% 30%, 100% 70%, 0 70%);
}
60% {
clip-path: polygon(0 5%, 100% 5%, 100% 60%, 0 60%);
}
80% {
clip-path: polygon(0 25%, 100% 25%, 100% 40%, 0 40%);
}
}
@keyframes glitch-2 {
0%, 100% { clip-path: polygon(0 55%, 100% 55%, 100% 100%, 0 100%); transform: translate(0); }
20% { clip-path: polygon(0 70%, 100% 70%, 100% 95%, 0 95%); }
40% { clip-path: polygon(0 40%, 100% 40%, 100% 85%, 0 85%); }
60% { clip-path: polygon(0 60%, 100% 60%, 100% 100%, 0 100%); }
80% { clip-path: polygon(0 50%, 100% 50%, 100% 90%, 0 90%); }
0%,
100% {
clip-path: polygon(0 55%, 100% 55%, 100% 100%, 0 100%);
transform: translate(0);
}
20% {
clip-path: polygon(0 70%, 100% 70%, 100% 95%, 0 95%);
}
40% {
clip-path: polygon(0 40%, 100% 40%, 100% 85%, 0 85%);
}
60% {
clip-path: polygon(0 60%, 100% 60%, 100% 100%, 0 100%);
}
80% {
clip-path: polygon(0 50%, 100% 50%, 100% 90%, 0 90%);
}
}
/* Grayscale filter with instant toggle */
@@ -160,7 +181,7 @@
/* Cyberpunk Glitch Effect for Button */
.glitch-btn {
position: relative;
animation: glitch 300ms cubic-bezier(.25, .46, .45, .94);
animation: glitch 300ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.glitch-layer {
@@ -177,21 +198,22 @@
}
.glitch-layer:first-of-type {
animation: glitch-1 300ms cubic-bezier(.25, .46, .45, .94);
animation: glitch-1 300ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
color: rgb(6 182 212); /* cyan-500 */
transform: translate(-2px, 0);
clip-path: polygon(0 0, 100% 0, 100% 35%, 0 35%);
}
.glitch-layer:last-of-type {
animation: glitch-2 300ms cubic-bezier(.25, .46, .45, .94);
animation: glitch-2 300ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
color: rgb(16 185 129); /* emerald-500 */
transform: translate(2px, 0);
clip-path: polygon(0 65%, 100% 65%, 100% 100%, 0 100%);
}
@keyframes glitch-1 {
0%, 100% {
0%,
100% {
transform: translate(0, 0);
clip-path: polygon(0 0, 100% 0, 100% 35%, 0 35%);
}
@@ -210,7 +232,8 @@
}
@keyframes glitch-2 {
0%, 100% {
0%,
100% {
transform: translate(0, 0);
clip-path: polygon(0 65%, 100% 65%, 100% 100%, 0 100%);
}
@@ -260,7 +283,8 @@
/* SCP-style subtle flicker hover */
@keyframes scp-flicker {
0%, 100% {
0%,
100% {
border-color: rgb(71 85 105);
box-shadow: 0 0 0 rgba(90, 139, 149, 0);
transform: translate(0, 0);
@@ -290,7 +314,9 @@
.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);
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 */
@@ -322,5 +348,143 @@
inset 0 0 10px rgba(255, 0, 128, 0.1);
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

@@ -28,11 +28,7 @@ export const metadata: Metadata = {
},
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ro" suppressHydrationWarning className={jetbrainsMono.variable}>
<body className="font-mono bg-zinc-50 text-slate-900 dark:bg-zinc-900 dark:text-slate-100 transition-colors duration-300">
@@ -51,7 +47,9 @@ export default function RootLayout({
<div className="container mx-auto px-4 py-8">
<div className="border-2 border-slate-300 dark:border-slate-800 p-6">
<p className="text-center text-slate-500 dark:text-slate-500 font-mono text-xs uppercase tracking-wider">
© 2025 <span style={{ color: 'var(--neon-cyan)' }}>//</span> BLOG & <span style={{ color: 'var(--neon-pink)' }}>PORTOFOLIU</span> <span style={{ color: 'var(--neon-cyan)' }}>//</span> ALL RIGHTS RESERVED
© 2025 <span style={{ color: 'var(--neon-cyan)' }}>//</span> BLOG &{' '}
<span style={{ color: 'var(--neon-pink)' }}>PORTOFOLIU</span>{' '}
<span style={{ color: 'var(--neon-cyan)' }}>//</span> ALL RIGHTS RESERVED
</p>
</div>
</div>

View File

@@ -22,19 +22,35 @@ export default async function HomePage() {
<div className="mb-8 flex items-center justify-between border-b-2 border-slate-300 dark:border-slate-800 pb-4">
<div className="flex items-center gap-3">
<Image src="/logo.png" alt="Logo" width={32} height={32} className="opacity-80" />
<span className="font-mono text-xs text-slate-500 uppercase tracking-widest">TERMINAL:// V2.0</span>
<span className="font-mono text-xs text-slate-500 uppercase tracking-widest">
TERMINAL:// V2.0
</span>
</div>
<div className="flex gap-4 items-center">
<Link href="/blog" className="font-mono text-xs text-slate-600 dark:text-slate-400 uppercase tracking-wider hover:text-cyan-600 dark:hover:text-cyan-400">[BLOG]</Link>
<Link href="/about" className="font-mono text-xs text-slate-600 dark:text-slate-400 uppercase tracking-wider hover:text-cyan-600 dark:hover:text-cyan-400">[ABOUT]</Link>
<Link
href="/blog"
className="font-mono text-xs text-slate-600 dark:text-slate-400 uppercase tracking-wider hover:text-cyan-600 dark:hover:text-cyan-400"
>
[BLOG]
</Link>
<Link
href="/about"
className="font-mono text-xs text-slate-600 dark:text-slate-400 uppercase tracking-wider hover:text-cyan-600 dark:hover:text-cyan-400"
>
[ABOUT]
</Link>
<ThemeToggle />
</div>
</div>
<div className="border-l-4 border-cyan-700 dark:border-cyan-900 pl-6 mb-8">
<p className="font-mono text-xs text-slate-500 dark:text-slate-500 uppercase tracking-widest mb-2">DOCUMENT LEVEL-1 // CLASSIFIED</p>
<p className="font-mono text-xs text-slate-500 dark:text-slate-500 uppercase tracking-widest mb-2">
DOCUMENT LEVEL-1 // CLASSIFIED
</p>
<h1 className="text-4xl md:text-6xl lg:text-7xl font-mono font-bold text-slate-900 dark:text-slate-100 uppercase tracking-tight mb-6">
BUILD. WRITE.<br/>SHARE.
BUILD. WRITE.
<br />
SHARE.
</h1>
<p className="text-base md:text-lg lg:text-xl text-slate-700 dark:text-slate-400 font-mono leading-relaxed max-w-2xl">
&gt; Explorează idei despre dezvoltare, design și tehnologie_
@@ -42,10 +58,16 @@ export default async function HomePage() {
</div>
<div className="flex gap-4 flex-wrap mt-12">
<Link href="/blog" className="px-6 md:px-8 py-3 md:py-4 bg-cyan-700 dark:bg-cyan-900 text-white dark:text-slate-100 border-2 border-cyan-600 dark:border-cyan-700 font-mono font-bold uppercase text-xs md:text-sm tracking-wider hover:bg-cyan-600 dark:hover:bg-cyan-800 hover:border-cyan-500 dark:hover:border-cyan-600 rounded-none transition-colors duration-200">
<Link
href="/blog"
className="px-6 md:px-8 py-3 md:py-4 bg-cyan-700 dark:bg-cyan-900 text-white dark:text-slate-100 border-2 border-cyan-600 dark:border-cyan-700 font-mono font-bold uppercase text-xs md:text-sm tracking-wider hover:bg-cyan-600 dark:hover:bg-cyan-800 hover:border-cyan-500 dark:hover:border-cyan-600 rounded-none transition-colors duration-200"
>
[EXPLOREAZĂ BLOG]
</Link>
<Link href="/about" className="px-6 md:px-8 py-3 md:py-4 bg-transparent text-slate-700 dark:text-slate-300 border-2 border-slate-400 dark:border-slate-700 font-mono font-bold uppercase text-xs md:text-sm tracking-wider hover:bg-slate-200 dark:hover:bg-slate-800 hover:border-slate-500 dark:hover:border-slate-600 rounded-none transition-colors duration-200">
<Link
href="/about"
className="px-6 md:px-8 py-3 md:py-4 bg-transparent text-slate-700 dark:text-slate-300 border-2 border-slate-400 dark:border-slate-700 font-mono font-bold uppercase text-xs md:text-sm tracking-wider hover:bg-slate-200 dark:hover:bg-slate-800 hover:border-slate-500 dark:hover:border-slate-600 rounded-none transition-colors duration-200"
>
[DESPRE MINE]
</Link>
</div>
@@ -81,7 +103,9 @@ export default async function HomePage() {
/>
) : (
<div className="w-full h-full bg-zinc-300 dark:bg-zinc-800 flex items-center justify-center">
<span className="font-mono text-6xl text-slate-400 dark:text-slate-700">#{String(index + 1).padStart(2, '0')}</span>
<span className="font-mono text-6xl text-slate-400 dark:text-slate-700">
#{String(index + 1).padStart(2, '0')}
</span>
</div>
)}
<div className="absolute inset-0 bg-zinc-100/60 dark:bg-zinc-900/60"></div>

View File

@@ -14,10 +14,11 @@ export function BlogCard({ post, variant }: BlogCardProps) {
if (!hasImage || variant === 'text-only') {
return (
<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">
<div className="border-l-4 pl-4 mb-4" style={{ borderColor: 'var(--neon-pink)' }}>
<article className="border border-slate-700 bg-slate-900 p-6 h-full cyber-glitch-hover">
<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">
{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>
</div>
<h3 className="font-mono text-xl font-bold text-zinc-100 uppercase mb-3">
@@ -27,8 +28,11 @@ export function BlogCard({ post, variant }: BlogCardProps) {
{post.frontmatter.description}
</p>
<div className="flex flex-wrap gap-2 mb-4">
{post.frontmatter.tags.map((tag) => (
<span key={tag} className="px-3 py-1 bg-zinc-800 border border-slate-700 text-cyan-400 font-mono text-xs uppercase">
{post.frontmatter.tags.map(tag => (
<span
key={tag}
className="px-3 py-1 bg-zinc-800 border border-slate-700 text-cyan-400 font-mono text-xs uppercase"
>
#{tag}
</span>
))}
@@ -44,7 +48,7 @@ export function BlogCard({ post, variant }: BlogCardProps) {
if (variant === 'image-side') {
return (
<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="md:w-1/3 relative h-64 md:h-auto bg-zinc-900">
<Image
@@ -56,7 +60,7 @@ export function BlogCard({ post, variant }: BlogCardProps) {
<div className="absolute inset-0 bg-zinc-900/60" />
</div>
<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">
{post.frontmatter.category} // {formatDate(post.frontmatter.date)}
</span>
@@ -68,8 +72,11 @@ export function BlogCard({ post, variant }: BlogCardProps) {
{post.frontmatter.description}
</p>
<div className="flex flex-wrap gap-2 mb-4">
{post.frontmatter.tags.map((tag) => (
<span key={tag} className="px-3 py-1 bg-zinc-800 border border-slate-700 text-cyan-400 font-mono text-xs uppercase">
{post.frontmatter.tags.map(tag => (
<span
key={tag}
className="px-3 py-1 bg-zinc-800 border border-slate-700 text-cyan-400 font-mono text-xs uppercase"
>
#{tag}
</span>
))}
@@ -86,7 +93,7 @@ export function BlogCard({ post, variant }: BlogCardProps) {
return (
<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">
<Image
src={post.frontmatter.image!}
@@ -97,9 +104,10 @@ export function BlogCard({ post, variant }: BlogCardProps) {
<div className="absolute inset-0 bg-zinc-900/60" />
</div>
<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">
{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>
</div>
<h3 className="font-mono text-xl font-bold text-zinc-100 uppercase mb-3">
@@ -109,8 +117,11 @@ export function BlogCard({ post, variant }: BlogCardProps) {
{post.frontmatter.description}
</p>
<div className="flex flex-wrap gap-2 mb-4">
{post.frontmatter.tags.map((tag) => (
<span key={tag} className="px-3 py-1 bg-zinc-800 border border-slate-700 text-cyan-400 font-mono text-xs uppercase">
{post.frontmatter.tags.map(tag => (
<span
key={tag}
className="px-3 py-1 bg-zinc-800 border border-slate-700 text-cyan-400 font-mono text-xs uppercase"
>
#{tag}
</span>
))}

View File

@@ -0,0 +1,57 @@
'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

@@ -1,14 +1,13 @@
'use client';
'use client'
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import Image from 'next/image';
import Link from 'next/link';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import Image from 'next/image'
import Link from 'next/link'
import { CodeBlock } from './code-block'
interface MarkdownRendererProps {
content: string;
content: string
}
export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
@@ -16,136 +15,74 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="text-4xl font-bold mt-8 mb-4">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-3xl font-bold mt-6 mb-3">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-2xl font-bold mt-4 mb-2">{children}</h3>
),
h4: ({ children }) => (
<h4 className="text-xl font-bold mt-3 mb-2">{children}</h4>
),
h5: ({ children }) => (
<h5 className="text-lg font-bold mt-2 mb-1">{children}</h5>
),
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>
),
h1: ({ children }) => {
const text = String(children)
const id = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
return <h1 id={id}>{children}</h1>
},
h2: ({ children }) => {
const text = String(children)
const id = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
return <h2 id={id}>{children}</h2>
},
h3: ({ children }) => {
const text = String(children)
const id = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
return <h3 id={id}>{children}</h3>
},
code: ({ inline, className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<SyntaxHighlighter
style={vscDarkPlus}
language={match[1]}
PreTag="div"
className="my-4 rounded-lg"
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm font-mono" {...props}>
{children}
</code>
);
const match = /language-(\w+)/.exec(className || '')
if (!inline && match) {
return <CodeBlock code={String(children).replace(/\n$/, '')} language={match[1]} />
}
return <code {...props}>{children}</code>
},
img: ({ src, alt }) => {
if (!src || typeof src !== 'string') return null;
const isExternal = src.startsWith('http://') || src.startsWith('https://');
if (!src || typeof src !== 'string') return null
const isExternal = src.startsWith('http://') || src.startsWith('https://')
if (isExternal) {
return (
<img
src={src}
alt={alt || ''}
className="my-4 rounded-lg max-w-full h-auto"
/>
);
return <img src={src} alt={alt || ''} className="w-full h-auto" />
}
return (
<div className="my-4 relative w-full h-auto">
<div className="relative w-full h-auto">
<Image
src={src}
alt={alt || ''}
width={800}
height={600}
className="rounded-lg"
style={{ width: '100%', height: 'auto' }}
/>
</div>
);
)
},
a: ({ href, children }) => {
if (!href) return <>{children}</>;
const isExternal = href.startsWith('http://') || href.startsWith('https://');
if (!href) return <>{children}</>
const isExternal = href.startsWith('http://') || href.startsWith('https://')
if (isExternal) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
)
}
return (
<Link href={href} className="text-blue-600 hover:underline">
{children}
</Link>
);
return <Link href={href}>{children}</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}
</ReactMarkdown>
);
)
}

View File

@@ -28,11 +28,17 @@ export function Navbar() {
}, [lastScrollY])
return (
<nav className={`border-b-4 border-slate-700 bg-slate-900 dark:bg-zinc-950 sticky top-0 z-50 ${isVisible ? 'navbar-visible' : 'navbar-hidden'}`}>
<nav
className={`border-b-4 border-slate-700 bg-slate-900 dark:bg-zinc-950 sticky top-0 z-50 ${isVisible ? 'navbar-visible' : 'navbar-hidden'}`}
>
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-8">
<Link href="/" className="font-mono text-sm uppercase tracking-wider transition-colors cursor-pointer" style={{ color: 'var(--neon-cyan)' }}>
<Link
href="/"
className="font-mono text-sm uppercase tracking-wider transition-colors cursor-pointer"
style={{ color: 'var(--neon-cyan)' }}
>
&lt; HOME
</Link>
<span className="font-mono text-sm text-zinc-100 dark:text-zinc-300 uppercase tracking-wider">
@@ -40,7 +46,10 @@ export function Navbar() {
</span>
</div>
<div className="flex items-center gap-6">
<Link href="/about" className="font-mono text-sm text-zinc-400 dark:text-zinc-500 uppercase tracking-wider hover:text-cyan-400 dark:hover:text-cyan-300 transition-colors cursor-pointer">
<Link
href="/about"
className="font-mono text-sm text-zinc-400 dark:text-zinc-500 uppercase tracking-wider hover:text-cyan-400 dark:hover:text-cyan-300 transition-colors cursor-pointer"
>
[ABOUT]
</Link>
<ThemeToggle />

View File

@@ -0,0 +1,38 @@
'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,13 +5,15 @@ interface SearchBarProps {
export function SearchBar({ searchQuery, onSearchChange }: SearchBarProps) {
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)]">
<span className="pl-4 pr-2 font-mono text-lg" style={{ color: 'var(--neon-cyan)' }}>&gt;</span>
<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>
<input
type="text"
placeholder="SEARCH POSTS..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
onChange={e => 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"
/>
</div>

View File

@@ -9,12 +9,30 @@ export function SortDropdown({ sortBy, onSortChange }: SortDropdownProps) {
return (
<select
value={sortBy}
onChange={(e) => onSortChange(e.target.value as SortOption)}
onChange={e => onSortChange(e.target.value as SortOption)}
className="border-2 border-slate-700 bg-zinc-900 dark:bg-zinc-900 text-zinc-100 dark:text-zinc-100 font-mono px-6 py-3 uppercase text-sm cyber-focus-pink transition-all hover:border-cyan-400 cursor-pointer"
>
<option value="newest" className="bg-zinc-900 text-zinc-100" style={{ backgroundColor: '#18181b', color: '#f4f4f5' }}>NEWEST FIRST</option>
<option value="oldest" className="bg-zinc-900 text-zinc-100" style={{ backgroundColor: '#18181b', color: '#f4f4f5' }}>OLDEST FIRST</option>
<option value="title" className="bg-zinc-900 text-zinc-100" style={{ backgroundColor: '#18181b', color: '#f4f4f5' }}>BY TITLE</option>
<option
value="newest"
className="bg-zinc-900 text-zinc-100"
style={{ backgroundColor: '#18181b', color: '#f4f4f5' }}
>
NEWEST FIRST
</option>
<option
value="oldest"
className="bg-zinc-900 text-zinc-100"
style={{ backgroundColor: '#18181b', color: '#f4f4f5' }}
>
OLDEST FIRST
</option>
<option
value="title"
className="bg-zinc-900 text-zinc-100"
style={{ backgroundColor: '#18181b', color: '#f4f4f5' }}
>
BY TITLE
</option>
</select>
)
}

View File

@@ -0,0 +1,116 @@
'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,93 @@
'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,16 +9,16 @@ export function TagFilter({ allTags, selectedTags, onToggleTag, onClearTags }: T
if (allTags.length === 0) return null
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">
FILTER BY TAG
</p>
<div className="flex flex-wrap gap-3">
{allTags.map((tag) => (
{allTags.map(tag => (
<button
key={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)
? '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'

View File

@@ -1,14 +1,14 @@
'use client';
'use client'
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Fragment } from 'react';
import { BreadcrumbsSchema } from './breadcrumbs-schema';
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Fragment } from 'react'
import { BreadcrumbsSchema } from './breadcrumbs-schema'
interface BreadcrumbItem {
label: string;
href: string;
current?: boolean;
label: string
href: string
current?: boolean
}
function HomeIcon({ className }: { className?: string }) {
@@ -27,25 +27,15 @@ function HomeIcon({ className }: { className?: string }) {
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</svg>
);
)
}
function ChevronIcon({ className }: { className?: string }) {
return (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
);
)
}
function formatSegmentLabel(segment: string): string {
@@ -53,36 +43,36 @@ function formatSegmentLabel(segment: string): string {
blog: 'Blog',
tags: 'Tag-uri',
about: 'Despre',
};
}
if (specialCases[segment]) {
return specialCases[segment];
return specialCases[segment]
}
return segment
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
.join(' ')
}
export function Breadcrumbs({ items }: { items?: BreadcrumbItem[] }) {
const pathname = usePathname();
const pathname = usePathname()
let breadcrumbs: BreadcrumbItem[] = items || [];
let breadcrumbs: BreadcrumbItem[] = items || []
if (!items) {
const segments = pathname.split('/').filter(Boolean);
const segments = pathname.split('/').filter(Boolean)
breadcrumbs = segments.map((segment, index) => {
const href = '/' + segments.slice(0, index + 1).join('/');
const label = formatSegmentLabel(segment);
const current = index === segments.length - 1;
const href = '/' + segments.slice(0, index + 1).join('/')
const label = formatSegmentLabel(segment)
const current = index === segments.length - 1
return { label, href, current };
});
return { label, href, current }
})
}
if (pathname === '/') {
return null;
return null
}
const schemaItems = [
@@ -92,7 +82,7 @@ export function Breadcrumbs({ items }: { items?: BreadcrumbItem[] }) {
name: item.label,
item: item.href,
})),
];
]
return (
<>
@@ -112,7 +102,7 @@ export function Breadcrumbs({ items }: { items?: BreadcrumbItem[] }) {
</Link>
</li>
{breadcrumbs.map((item) => (
{breadcrumbs.map(item => (
<Fragment key={item.href}>
<li className="text-gray-400 flex-shrink-0">
<ChevronIcon className="w-4 h-4" />
@@ -141,5 +131,5 @@ export function Breadcrumbs({ items }: { items?: BreadcrumbItem[] }) {
</nav>
<BreadcrumbsSchema items={schemaItems} />
</>
);
)
}

View File

@@ -1,25 +1,25 @@
interface BreadcrumbSchemaItem {
position: number;
name: string;
item: string;
position: number
name: string
item: string
}
export function BreadcrumbsSchema({ items }: { items: BreadcrumbSchemaItem[] }) {
const structuredData = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item) => ({
itemListElement: items.map(item => ({
'@type': 'ListItem',
position: item.position,
name: item.name,
item: `http://localhost:3000${item.item}`,
})),
};
}
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
);
)
}

View File

@@ -43,7 +43,8 @@ export function ThemeToggle() {
className={`
relative font-mono text-xs uppercase tracking-wider
px-3 py-1 border-2 transition-all duration-300
${theme === 'dark'
${
theme === 'dark'
? 'text-cyan-400 border-cyan-900 hover:border-cyan-700 bg-cyan-950/20'
: 'text-emerald-600 border-emerald-700 hover:border-emerald-500 bg-emerald-50/50'
}
@@ -52,9 +53,7 @@ export function ThemeToggle() {
`}
aria-label="Toggle theme"
>
<span className="relative z-10">
{theme === 'dark' ? '[DARK MODE]' : '[LIGHT MODE]'}
</span>
<span className="relative z-10">{theme === 'dark' ? '[DARK MODE]' : '[LIGHT MODE]'}</span>
{isGlitching && (
<>
<span className="glitch-layer" aria-hidden="true">

View File

@@ -1,10 +1,10 @@
---
title: "Getting Started with Next.js 15"
description: "Learn how to build modern web applications with Next.js 15 and TypeScript."
date: "2025-01-07"
author: "John Doe"
category: "Tutorial"
tags: ["nextjs", "typescript", "tutorial"]
title: 'Getting Started with Next.js 15'
description: 'Learn how to build modern web applications with Next.js 15 and TypeScript.'
date: '2025-01-07'
author: 'John Doe'
category: 'Tutorial'
tags: ['nextjs', 'typescript', 'tutorial']
---
# Getting Started with Next.js 15

View File

@@ -1,10 +1,10 @@
---
title: "Articol Tehnic din Subdirector"
description: "Test pentru subdirectoare și organizare ierarhică"
date: "2025-01-10"
author: "Tech Writer"
category: "Tehnologie"
tags: ["nextjs", "react", "typescript"]
title: 'Articol Tehnic din Subdirector'
description: 'Test pentru subdirectoare și organizare ierarhică'
date: '2025-01-10'
author: 'Tech Writer'
category: 'Tehnologie'
tags: ['nextjs', 'react', 'typescript']
draft: false
---
@@ -25,14 +25,14 @@ Next.js este un framework React puternic care oferă:
```typescript
interface User {
id: number;
name: string;
email: string;
id: number
name: string
email: string
}
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json();
const response = await fetch(`/api/users/${id}`)
return response.json()
}
```

View File

@@ -1,17 +1,17 @@
---
title: "Test Complet Markdown"
description: "Un articol de test care demonstrează toate elementele markdown suportate"
date: "2025-01-15"
author: "Test Author"
category: "Tutorial"
tags: ["markdown", "test", "demo"]
image: "/38636.jpg"
title: 'Test Complet Markdown'
description: 'Un articol de test care demonstrează toate elementele markdown suportate'
date: '2025-01-15'
author: 'Test Author'
category: 'Tutorial'
tags: ['markdown', 'test', 'demo']
image: '/38636.jpg'
draft: false
---
# Heading 1
Acesta este un paragraf normal cu **text bold** și *text italic*. Putem combina ***bold și italic***.
Acesta este un paragraf normal cu **text bold** și _text italic_. Putem combina **_bold și italic_**.
## Heading 2
@@ -47,11 +47,11 @@ Bloc de cod JavaScript:
```javascript
function greet(name) {
console.log(`Hello, ${name}!`);
return true;
console.log(`Hello, ${name}!`)
return true
}
greet("World");
greet('World')
```
Bloc de cod Python:
@@ -85,7 +85,7 @@ print(f"Result: {result}")
## Tabele
| Coloana 1 | Coloana 2 | Coloana 3 |
|-----------|-----------|-----------|
| --------- | --------- | --------- |
| Celula 1 | Celula 2 | Celula 3 |
| Date 1 | Date 2 | Date 3 |
| Info 1 | Info 2 | Info 3 |

33
eslint.config.mjs Normal file
View File

@@ -0,0 +1,33 @@
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import prettier from 'eslint-config-prettier'
export default [
js.configs.recommended,
...tseslint.configs.recommended,
prettier,
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
},
{
ignores: [
'node_modules/',
'.next/',
'out/',
'build/',
'dist/',
'.cache/',
'*.config.js',
'public/',
'coverage/',
],
},
]

View File

@@ -1,43 +1,43 @@
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { FrontMatter, Post } from './types/frontmatter';
import { generateExcerpt } from './utils';
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import { FrontMatter, Post } from './types/frontmatter'
import { generateExcerpt } from './utils'
const POSTS_PATH = path.join(process.cwd(), 'content', 'blog');
const POSTS_PATH = path.join(process.cwd(), 'content', 'blog')
export function sanitizePath(inputPath: string): string {
const normalized = path.normalize(inputPath).replace(/^(\.\.[\/\\])+/, '');
const normalized = path.normalize(inputPath).replace(/^(\.\.[/\\])+/, '')
if (normalized.includes('..') || path.isAbsolute(normalized)) {
throw new Error('Invalid path');
throw new Error('Invalid path')
}
return normalized;
return normalized
}
export function calculateReadingTime(content: string): number {
const wordsPerMinute = 200;
const words = content.trim().split(/\s+/).length;
return Math.ceil(words / wordsPerMinute);
const wordsPerMinute = 200
const words = content.trim().split(/\s+/).length
return Math.ceil(words / wordsPerMinute)
}
export function validateFrontmatter(data: any): FrontMatter {
if (!data.title || typeof data.title !== 'string') {
throw new Error('Invalid title');
throw new Error('Invalid title')
}
if (!data.description || typeof data.description !== 'string') {
throw new Error('Invalid description');
throw new Error('Invalid description')
}
if (!data.date || typeof data.date !== 'string') {
throw new Error('Invalid date');
throw new Error('Invalid date')
}
if (!data.author || typeof data.author !== 'string') {
throw new Error('Invalid author');
throw new Error('Invalid author')
}
if (!data.category || typeof data.category !== 'string') {
throw new Error('Invalid category');
throw new Error('Invalid category')
}
if (!Array.isArray(data.tags) || data.tags.length === 0 || data.tags.length > 3) {
throw new Error('Tags must be array with 1-3 items');
throw new Error('Tags must be array with 1-3 items')
}
return {
@@ -49,21 +49,21 @@ export function validateFrontmatter(data: any): FrontMatter {
tags: data.tags,
image: data.image,
draft: data.draft || false,
};
}
}
export function getPostBySlug(slug: string | string[]): Post | null {
const slugArray = Array.isArray(slug) ? slug : [slug];
const sanitized = slugArray.map(s => sanitizePath(s));
const fullPath = path.join(POSTS_PATH, ...sanitized) + '.md';
const slugArray = Array.isArray(slug) ? slug : slug.split('/')
const sanitized = slugArray.map(s => sanitizePath(s))
const fullPath = path.join(POSTS_PATH, ...sanitized) + '.md'
if (!fs.existsSync(fullPath)) {
return null;
return null
}
const fileContents = fs.readFileSync(fullPath, 'utf8');
const { data, content } = matter(fileContents);
const frontmatter = validateFrontmatter(data);
const fileContents = fs.readFileSync(fullPath, 'utf8')
const { data, content } = matter(fileContents)
const frontmatter = validateFrontmatter(data)
return {
slug: sanitized.join('/'),
@@ -71,84 +71,86 @@ export function getPostBySlug(slug: string | string[]): Post | null {
content,
readingTime: calculateReadingTime(content),
excerpt: generateExcerpt(content),
};
}
}
export function getAllPosts(includeContent = false): Post[] {
const posts: Post[] = [];
const posts: Post[] = []
function walkDir(dir: string, prefix = ''): void {
const files = fs.readdirSync(dir);
const files = fs.readdirSync(dir)
for (const file of files) {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
const filePath = path.join(dir, file)
const stat = fs.statSync(filePath)
if (stat.isDirectory()) {
walkDir(filePath, prefix ? `${prefix}/${file}` : file);
walkDir(filePath, prefix ? `${prefix}/${file}` : file)
} else if (file.endsWith('.md')) {
const slug = prefix ? `${prefix}/${file.replace(/\.md$/, '')}` : file.replace(/\.md$/, '');
const slug = prefix ? `${prefix}/${file.replace(/\.md$/, '')}` : file.replace(/\.md$/, '')
try {
const post = getPostBySlug(slug.split('/'));
const post = getPostBySlug(slug.split('/'))
if (post && !post.frontmatter.draft) {
posts.push(includeContent ? post : { ...post, content: '' });
posts.push(includeContent ? post : { ...post, content: '' })
}
} catch (error) {
console.error(`Error loading post ${slug}:`, error);
console.error(`Error loading post ${slug}:`, error)
}
}
}
}
if (fs.existsSync(POSTS_PATH)) {
walkDir(POSTS_PATH);
walkDir(POSTS_PATH)
}
return posts.sort((a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime());
return posts.sort(
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime()
)
}
export async function getRelatedPosts(currentSlug: string, limit = 3): Promise<Post[]> {
const currentPost = getPostBySlug(currentSlug);
if (!currentPost) return [];
const currentPost = getPostBySlug(currentSlug)
if (!currentPost) return []
const allPosts = getAllPosts(false);
const { category, tags } = currentPost.frontmatter;
const allPosts = getAllPosts(false)
const { category, tags } = currentPost.frontmatter
const scored = allPosts
.filter(post => post.slug !== currentSlug)
.map(post => {
let score = 0;
if (post.frontmatter.category === category) score += 3;
score += post.frontmatter.tags.filter(tag => tags.includes(tag)).length * 2;
return { post, score };
let score = 0
if (post.frontmatter.category === category) score += 3
score += post.frontmatter.tags.filter(tag => tags.includes(tag)).length * 2
return { post, score }
})
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score);
.sort((a, b) => b.score - a.score)
return scored.slice(0, limit).map(({ post }) => post);
return scored.slice(0, limit).map(({ post }) => post)
}
export function getAllPostSlugs(): string[][] {
const slugs: string[][] = [];
const slugs: string[][] = []
function walkDir(dir: string, prefix: string[] = []): void {
const files = fs.readdirSync(dir);
const files = fs.readdirSync(dir)
for (const file of files) {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
const filePath = path.join(dir, file)
const stat = fs.statSync(filePath)
if (stat.isDirectory()) {
walkDir(filePath, [...prefix, file]);
walkDir(filePath, [...prefix, file])
} else if (file.endsWith('.md')) {
slugs.push([...prefix, file.replace(/\.md$/, '')]);
slugs.push([...prefix, file.replace(/\.md$/, '')])
}
}
}
if (fs.existsSync(POSTS_PATH)) {
walkDir(POSTS_PATH);
walkDir(POSTS_PATH)
}
return slugs;
return slugs
}

View File

@@ -1,22 +1,22 @@
export interface FrontMatter {
title: string;
description: string;
date: string;
author: string;
category: string;
tags: string[];
image?: string;
draft?: boolean;
title: string
description: string
date: string
author: string
category: string
tags: string[]
image?: string
draft?: boolean
}
export interface Post {
slug: string;
frontmatter: FrontMatter;
content: string;
readingTime: number;
excerpt: string;
slug: string
frontmatter: FrontMatter
content: string
readingTime: number
excerpt: string
}
export interface BlogParams {
slug: string[];
slug: string[]
}

View File

@@ -1,47 +1,65 @@
export function formatDate(dateString: string): string {
const date = new Date(dateString);
const date = new Date(dateString)
const months = [
'ianuarie', 'februarie', 'martie', 'aprilie', 'mai', 'iunie',
'iulie', 'august', 'septembrie', 'octombrie', 'noiembrie', 'decembrie'
];
'ianuarie',
'februarie',
'martie',
'aprilie',
'mai',
'iunie',
'iulie',
'august',
'septembrie',
'octombrie',
'noiembrie',
'decembrie',
]
return `${date.getDate()} ${months[date.getMonth()]} ${date.getFullYear()}`;
return `${date.getDate()} ${months[date.getMonth()]} ${date.getFullYear()}`
}
export function formatRelativeDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffTime = Math.abs(now.getTime() - date.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
const date = new Date(dateString)
const now = new Date()
const diffTime = Math.abs(now.getTime() - date.getTime())
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
if (diffDays === 0) return 'astăzi';
if (diffDays === 1) return 'ieri';
if (diffDays < 7) return `acum ${diffDays} zile`;
if (diffDays < 30) return `acum ${Math.floor(diffDays / 7)} săptămâni`;
if (diffDays < 365) return `acum ${Math.floor(diffDays / 30)} luni`;
return `acum ${Math.floor(diffDays / 365)} ani`;
if (diffDays === 0) return 'astăzi'
if (diffDays === 1) return 'ieri'
if (diffDays < 7) return `acum ${diffDays} zile`
if (diffDays < 30) return `acum ${Math.floor(diffDays / 7)} săptămâni`
if (diffDays < 365) return `acum ${Math.floor(diffDays / 30)} luni`
return `acum ${Math.floor(diffDays / 365)} ani`
}
export function generateExcerpt(content: string, maxLength = 160): string {
const text = content
.replace(/^---[\s\S]*?---/, '')
.replace(/!\[.*?\]\(.*?\)/g, '')
.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/[#*`]/g, '')
.trim();
.trim()
if (text.length <= maxLength) return text;
if (text.length <= maxLength) return text
const truncated = text.slice(0, maxLength);
const lastSpace = truncated.lastIndexOf(' ');
return truncated.slice(0, lastSpace) + '...';
const truncated = text.slice(0, maxLength)
const lastSpace = truncated.lastIndexOf(' ')
return truncated.slice(0, lastSpace) + '...'
}
export function generateSlug(title: string): string {
const romanianMap: Record<string, string> = {
'ă': 'a', 'â': 'a', 'î': 'i', 'ș': 's', 'ț': 't',
'Ă': 'a', 'Â': 'a', 'Î': 'i', 'Ș': 's', 'Ț': 't'
};
ă: 'a',
â: 'a',
î: 'i',
ș: 's',
ț: 't',
Ă: 'a',
Â: 'a',
Î: 'i',
Ș: 's',
Ț: 't',
}
return title
.split('')
@@ -49,5 +67,5 @@ export function generateSlug(title: string): string {
.join('')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
.replace(/^-+|-+$/g, '')
}

5075
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,10 @@
"dev": "next dev -p 3030",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint": "eslint . --ext .ts,.tsx,.js,.jsx",
"lint:fix": "eslint . --ext .ts,.tsx,.js,.jsx --fix",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
"format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
"validate-posts": "node scripts/validate-posts.js"
},
"repository": {
@@ -38,5 +41,17 @@
"remark-gfm": "^4.0.1",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.39.1",
"@typescript-eslint/eslint-plugin": "^8.46.4",
"@typescript-eslint/parser": "^8.46.4",
"eslint": "^9.39.1",
"eslint-config-next": "^16.0.3",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"prettier": "^3.6.2",
"typescript-eslint": "^8.46.4"
}
}

View File

@@ -2,9 +2,9 @@
module.exports = {
darkMode: 'class',
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
@@ -24,7 +24,7 @@ module.exports = {
'dark-primary': '#18181b',
'dark-secondary': '#0f172a',
'dark-tertiary': '#1e293b',
'accent': {
accent: {
DEFAULT: '#164e63',
hover: '#155e75',
light: '#0e7490',
@@ -39,10 +39,10 @@ module.exports = {
},
},
animation: {
'glitch': 'glitch 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) both',
'flicker': 'flicker 0.15s infinite',
'scanline': 'scanline 8s linear infinite',
'noise': 'noise 0.2s infinite',
glitch: 'glitch 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) both',
flicker: 'flicker 0.15s infinite',
scanline: 'scanline 8s linear infinite',
noise: 'noise 0.2s infinite',
},
keyframes: {
glitch: {

View File

@@ -1,11 +1,7 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -23,9 +19,7 @@
}
],
"paths": {
"@/*": [
"./*"
]
"@/*": ["./*"]
}
},
"include": [
@@ -35,7 +29,5 @@
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
"exclude": ["node_modules"]
}