From 31361311824bad3b9611b555564d539abfd3b21e Mon Sep 17 00:00:00 2001 From: RJ Date: Wed, 19 Nov 2025 13:25:36 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20added=20tags=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/main.yml | 8 +- app/blog/[...slug]/page.tsx | 5 +- app/page.tsx | 14 ++- app/tags/[tag]/not-found.tsx | 40 ++++++ app/tags/[tag]/page.tsx | 203 +++++++++++++++++++++++++++++++ app/tags/layout.tsx | 16 +++ app/tags/page.tsx | 146 ++++++++++++++++++++++ components/blog/popular-tags.tsx | 44 +++++++ components/blog/tag-badge.tsx | 20 +++ components/blog/tag-cloud.tsx | 36 ++++++ components/blog/tag-list.tsx | 37 ++++++ lib/tags.ts | 131 ++++++++++++++++++++ next-env.d.ts | 2 +- package.json | 2 +- 14 files changed, 689 insertions(+), 15 deletions(-) create mode 100644 app/tags/[tag]/not-found.tsx create mode 100644 app/tags/[tag]/page.tsx create mode 100644 app/tags/layout.tsx create mode 100644 app/tags/page.tsx create mode 100644 components/blog/popular-tags.tsx create mode 100644 components/blog/tag-badge.tsx create mode 100644 components/blog/tag-cloud.tsx create mode 100644 components/blog/tag-list.tsx create mode 100644 lib/tags.ts diff --git a/.gitea/workflows/main.yml b/.gitea/workflows/main.yml index a53a079..9c4b848 100644 --- a/.gitea/workflows/main.yml +++ b/.gitea/workflows/main.yml @@ -36,18 +36,12 @@ jobs: # ============================================ lint: name: 🔍 Code Quality Checks - runs-on: ubuntu-latest + runs-on: node-latest steps: - name: 🔎 Checkout code uses: actions/checkout@v4 - - name: 📦 Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - - name: 📥 Install dependencies run: npm ci diff --git a/app/blog/[...slug]/page.tsx b/app/blog/[...slug]/page.tsx index 1e3b1d8..aa30749 100644 --- a/app/blog/[...slug]/page.tsx +++ b/app/blog/[...slug]/page.tsx @@ -101,12 +101,13 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
{post.frontmatter.tags.map((tag: string) => ( - #{tag} - + ))}
diff --git a/app/page.tsx b/app/page.tsx index 46bfce0..acbe903 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -140,16 +140,22 @@ export default async function HomePage() { ))} - {allPosts.length > 6 && ( -
+
+ {allPosts.length > 6 && ( [VEZI TOATE ARTICOLELE] >> -
- )} + )} + + [VEZI TOATE TAG-URILE] >> + +
diff --git a/app/tags/[tag]/not-found.tsx b/app/tags/[tag]/not-found.tsx new file mode 100644 index 0000000..4b9e6c6 --- /dev/null +++ b/app/tags/[tag]/not-found.tsx @@ -0,0 +1,40 @@ +import Link from 'next/link'; + +export default function TagNotFound() { + return ( +
+
+
+
+

404

+
+

+ ERROR: TAG NOT FOUND +

+

+ TAG DOES NOT EXIST +

+

+ > THE REQUESTED TAG COULD NOT BE LOCATED IN THE DATABASE +
+ > IT MAY HAVE BEEN REMOVED OR NEVER EXISTED +

+
+ + > VIEW ALL TAGS + + + > VIEW ALL POSTS + +
+
+
+
+ ); +} diff --git a/app/tags/[tag]/page.tsx b/app/tags/[tag]/page.tsx new file mode 100644 index 0000000..2d970d9 --- /dev/null +++ b/app/tags/[tag]/page.tsx @@ -0,0 +1,203 @@ +import { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import Link from 'next/link'; +import { + getAllTags, + getPostsByTag, + getTagInfo, + getRelatedTags +} from '@/lib/tags'; +import { TagList } from '@/components/blog/tag-list'; +import { formatDate } from '@/lib/utils'; + +export async function generateStaticParams() { + const tags = await getAllTags(); + return tags.map(tag => ({ tag: tag.slug })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ tag: string }>; +}): Promise { + const { tag } = await params; + const tagInfo = await getTagInfo(tag); + + if (!tagInfo) { + return { title: 'Tag negăsit' }; + } + + return { + title: `Tag: ${tagInfo.name}`, + description: `Articole marcate cu #${tagInfo.name}. ${tagInfo.count} articole disponibile.`, + openGraph: { + title: `Tag: ${tagInfo.name}`, + description: `Explorează ${tagInfo.count} articole despre ${tagInfo.name}`, + }, + }; +} + +function PostCard({ post }: { post: any }) { + return ( +
+ {post.frontmatter.image && ( + {post.frontmatter.title} + )} + +
+ + > + {post.readingTime} min +
+ +

+ + {post.frontmatter.title} + +

+ +

+ {post.frontmatter.description} +

+ + {post.frontmatter.tags && ( + + )} +
+ ); +} + +export default async function TagPage({ + params, +}: { + params: Promise<{ tag: string }>; +}) { + const { tag } = await params; + const tagInfo = await getTagInfo(tag); + + if (!tagInfo) { + notFound(); + } + + const posts = await getPostsByTag(tag); + const relatedTags = await getRelatedTags(tag); + + return ( +
+
+
+
+
+

+ TAG ARCHIVE +

+

+ #{tagInfo.name} +

+

+ > {tagInfo.count} {tagInfo.count === 1 ? 'DOCUMENT' : 'DOCUMENTS'} +

+
+
+ + > ALL TAGS + +
+
+
+ +
+
+ {posts.length === 0 ? ( +
+

+ > NO DOCUMENTS FOUND +

+ + > VIEW ALL POSTS + +
+ ) : ( +
+ {posts.map(post => ( + + ))} +
+ )} +
+ + +
+
+
+ ); +} diff --git a/app/tags/layout.tsx b/app/tags/layout.tsx new file mode 100644 index 0000000..c9a69d2 --- /dev/null +++ b/app/tags/layout.tsx @@ -0,0 +1,16 @@ +import { Metadata } from 'next' +import { Navbar } from '@/components/blog/navbar' + +export const metadata: Metadata = { + title: 'Tag-uri', + description: 'Explorează articolele după tag-uri', +} + +export default function TagsLayout({ children }: { children: React.ReactNode }) { + return ( + <> + + {children} + + ) +} diff --git a/app/tags/page.tsx b/app/tags/page.tsx new file mode 100644 index 0000000..7a06d21 --- /dev/null +++ b/app/tags/page.tsx @@ -0,0 +1,146 @@ +import { Metadata } from 'next'; +import Link from 'next/link'; +import { getAllTags, getTagCloud } from '@/lib/tags'; +import { TagCloud } from '@/components/blog/tag-cloud'; +import { TagBadge } from '@/components/blog/tag-badge'; + +export const metadata: Metadata = { + title: 'Tag-uri', + description: 'Explorează articolele după tag-uri', +}; + +export default async function TagsPage() { + const allTags = await getAllTags(); + const tagCloud = await getTagCloud(); + + if (allTags.length === 0) { + return ( +
+
+
+

+ TAG DATABASE +

+

+ > NO TAGS AVAILABLE +

+ + > VIEW ALL POSTS + +
+
+
+ ); + } + + const groupedTags = allTags.reduce((acc, tag) => { + const firstLetter = tag.name[0].toUpperCase(); + if (!acc[firstLetter]) { + acc[firstLetter] = []; + } + acc[firstLetter].push(tag); + return acc; + }, {} as Record); + + const sortedLetters = Object.keys(groupedTags).sort(); + + return ( +
+
+
+

+ DOCUMENT TYPE: TAG DATABASE +

+

+ TAG REGISTRY +

+

+ > TOTAL TAGS: {allTags.length} +

+
+ +
+
+

+ SECTION: TAG CLOUD VISUALIZATION +

+
+
+ +
+
+ +
+
+

+ SECTION: ALPHABETICAL INDEX +

+
+
+ {sortedLetters.map(letter => ( +
+
+

+ > [{letter}] +

+
+
+ {groupedTags[letter].map(tag => ( + + #{tag.name} + + + ))} +
+
+ ))} +
+
+ +
+
+

+ DOCUMENT STATISTICS +

+

+ TAG METRICS +

+
+
+
+
+ {allTags.length} +
+
+ TOTAL TAGS +
+
+
+
+ {Math.max(...allTags.map(t => t.count))} +
+
+ MAX POSTS/TAG +
+
+
+
+ {Math.round(allTags.reduce((sum, t) => sum + t.count, 0) / allTags.length)} +
+
+ AVG POSTS/TAG +
+
+
+
+
+
+ ); +} diff --git a/components/blog/popular-tags.tsx b/components/blog/popular-tags.tsx new file mode 100644 index 0000000..87f09b2 --- /dev/null +++ b/components/blog/popular-tags.tsx @@ -0,0 +1,44 @@ +import Link from 'next/link'; +import { getPopularTags } from '@/lib/tags'; +import { TagBadge } from './tag-badge'; + +export async function PopularTags({ limit = 5 }: { limit?: number }) { + const tags = await getPopularTags(limit); + + if (tags.length === 0) return null; + + return ( +
+
+

+ POPULAR TAGS +

+
+
+ {tags.map((tag, index) => ( + +
+ + [{index + 1}] + + + #{tag.name} + +
+ + + ))} +
+ + > VIEW ALL TAGS + +
+ ); +} diff --git a/components/blog/tag-badge.tsx b/components/blog/tag-badge.tsx new file mode 100644 index 0000000..6f4032d --- /dev/null +++ b/components/blog/tag-badge.tsx @@ -0,0 +1,20 @@ +interface TagBadgeProps { + count: number; + className?: string; +} + +export function TagBadge({ count, className = '' }: TagBadgeProps) { + return ( + + {count} + + ); +} diff --git a/components/blog/tag-cloud.tsx b/components/blog/tag-cloud.tsx new file mode 100644 index 0000000..50c0b00 --- /dev/null +++ b/components/blog/tag-cloud.tsx @@ -0,0 +1,36 @@ +import Link from 'next/link'; +import { TagInfo } from '@/lib/tags'; + +interface TagCloudProps { + tags: Array; +} + +export function TagCloud({ tags }: TagCloudProps) { + const sizeClasses = { + sm: 'text-xs opacity-70', + md: 'text-sm', + lg: 'text-base font-bold', + xl: 'text-lg font-bold', + }; + + return ( +
+ {tags.map(tag => ( + + #{tag.name} + + ))} +
+ ); +} diff --git a/components/blog/tag-list.tsx b/components/blog/tag-list.tsx new file mode 100644 index 0000000..187a391 --- /dev/null +++ b/components/blog/tag-list.tsx @@ -0,0 +1,37 @@ +import Link from 'next/link'; +import { slugifyTag } from '@/lib/tags'; + +interface TagListProps { + tags: (string | undefined)[]; + variant?: 'default' | 'minimal' | 'colored'; + className?: string; +} + +export function TagList({ tags, variant = 'default', className = '' }: TagListProps) { + const validTags = tags.filter(Boolean) as string[]; + + if (validTags.length === 0) return null; + + const baseClasses = 'inline-flex items-center font-mono text-xs uppercase border transition-colors'; + + const variants = { + default: 'px-3 py-1 bg-zinc-900 border-slate-700 text-zinc-400 hover:border-cyan-400 hover:text-cyan-400', + minimal: 'px-2 py-0.5 border-transparent text-zinc-500 hover:text-cyan-400', + colored: 'px-3 py-1 bg-cyan-900 border-cyan-700 text-cyan-300 hover:bg-cyan-800 hover:border-cyan-600', + }; + + return ( +
+ {validTags.map(tag => ( + + # + {tag} + + ))} +
+ ); +} diff --git a/lib/tags.ts b/lib/tags.ts new file mode 100644 index 0000000..f2b57da --- /dev/null +++ b/lib/tags.ts @@ -0,0 +1,131 @@ +import { getAllPosts } from './markdown'; +import type { Post } from './types/frontmatter'; + +export interface TagInfo { + name: string; + slug: string; + count: number; +} + +export interface TagWithPosts { + tag: TagInfo; + posts: Post[]; +} + +export function slugifyTag(tag: string): string { + return tag + .toLowerCase() + .replace(/[ăâ]/g, 'a') + .replace(/[îï]/g, 'i') + .replace(/[șş]/g, 's') + .replace(/[țţ]/g, 't') + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +export async function getAllTags(): Promise { + const posts = getAllPosts(); + const tagMap = new Map(); + + posts.forEach(post => { + const tags = post.frontmatter.tags?.filter(Boolean) || []; + tags.forEach(tag => { + const count = tagMap.get(tag) || 0; + tagMap.set(tag, count + 1); + }); + }); + + return Array.from(tagMap.entries()) + .map(([name, count]) => ({ + name, + slug: slugifyTag(name), + count + })) + .sort((a, b) => b.count - a.count); +} + +export async function getPostsByTag(tagSlug: string): Promise { + const posts = getAllPosts(); + + return posts.filter(post => { + const tags = post.frontmatter.tags?.filter(Boolean) || []; + return tags.some(tag => slugifyTag(tag) === tagSlug); + }); +} + +export async function getTagInfo(tagSlug: string): Promise { + const allTags = await getAllTags(); + return allTags.find(tag => tag.slug === tagSlug) || null; +} + +export async function getPopularTags(limit = 10): Promise { + const allTags = await getAllTags(); + return allTags.slice(0, limit); +} + +export async function getRelatedTags(tagSlug: string, limit = 5): Promise { + const posts = await getPostsByTag(tagSlug); + const relatedTagMap = new Map(); + + posts.forEach(post => { + const tags = post.frontmatter.tags?.filter(Boolean) || []; + tags.forEach(tag => { + const slug = slugifyTag(tag); + if (slug !== tagSlug) { + const count = relatedTagMap.get(tag) || 0; + relatedTagMap.set(tag, count + 1); + } + }); + }); + + return Array.from(relatedTagMap.entries()) + .map(([name, count]) => ({ + name, + slug: slugifyTag(name), + count + })) + .sort((a, b) => b.count - a.count) + .slice(0, limit); +} + +export function validateTags(tags: any): string[] { + if (!tags) return []; + + if (!Array.isArray(tags)) { + console.warn('Tags should be an array'); + return []; + } + + const validTags = tags + .filter(tag => tag && typeof tag === 'string') + .slice(0, 3); + + if (tags.length > 3) { + console.warn(`Too many tags provided (${tags.length}). Limited to first 3.`); + } + + return validTags; +} + +export async function getTagCloud(): Promise> { + const tags = await getAllTags(); + if (tags.length === 0) return []; + + const maxCount = Math.max(...tags.map(t => t.count)); + const minCount = Math.min(...tags.map(t => t.count)); + const range = maxCount - minCount || 1; + + return tags.map(tag => { + const normalized = (tag.count - minCount) / range; + let size: 'sm' | 'md' | 'lg' | 'xl'; + + if (normalized < 0.25) size = 'sm'; + else if (normalized < 0.5) size = 'md'; + else if (normalized < 0.75) size = 'lg'; + else size = 'xl'; + + return { ...tag, size }; + }); +} diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index 4618202..7635cb5 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "next dev -p 3030", "build": "next build", - "start": "next start", + "start": "next start -p 3030", "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}\"",