🏷️ added tags system and workflows #6

Merged
raresj merged 2 commits from feat/add-tags-system into master 2025-11-19 13:47:41 +00:00
15 changed files with 711 additions and 37 deletions

View File

@@ -36,18 +36,12 @@ jobs:
# ============================================ # ============================================
lint: lint:
name: 🔍 Code Quality Checks name: 🔍 Code Quality Checks
runs-on: ubuntu-latest runs-on: node-latest
steps: steps:
- name: 🔎 Checkout code - name: 🔎 Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: 📦 Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: 📥 Install dependencies - name: 📥 Install dependencies
run: npm ci run: npm ci
@@ -102,11 +96,11 @@ jobs:
# - Uses Dockerfile.nextjs from project root # - Uses Dockerfile.nextjs from project root
# - Tags image with both 'latest' and commit SHA # - Tags image with both 'latest' and commit SHA
# - Enables inline cache for faster subsequent builds # - Enables inline cache for faster subsequent builds
# -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} ❗ do this if deploying on PR creation
docker build \ docker build \
--progress=plain \ --progress=plain \
--build-arg BUILDKIT_INLINE_CACHE=1 \ --build-arg BUILDKIT_INLINE_CACHE=1 \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \ -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
-f Dockerfile.nextjs \ -f Dockerfile.nextjs \
. .
@@ -120,11 +114,11 @@ jobs:
# Push both tags (latest and commit SHA) # Push both tags (latest and commit SHA)
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} # docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
echo "✅ Image pushed successfully" echo "✅ Image pushed successfully"
echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}" # echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}"
# ============================================ # ============================================
# Job 2: Deploy to Production Server # Job 2: Deploy to Production Server
@@ -135,7 +129,7 @@ jobs:
needs: [build-and-push] # Wait for build job to complete needs: [build-and-push] # Wait for build job to complete
environment: environment:
name: production name: production
url: http://your-production-url.com # Update with your actual production URL url: http://192.168.1.54:3030 # Update with your actual production URL
steps: steps:
- name: 🔎 Checkout code (for docker-compose file) - name: 🔎 Checkout code (for docker-compose file)
@@ -148,8 +142,8 @@ jobs:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_URL: ${{ env.REGISTRY }} REGISTRY_URL: ${{ env.REGISTRY }}
with: with:
host: ${{ secrets.PRODUCTION_HOST }} host: ${{ vars.PRODUCTION_HOST }}
username: ${{ secrets.PRODUCTION_USER }} username: ${{ vars.PRODUCTION_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }} key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 22 port: 22
envs: REGISTRY_PASSWORD,REGISTRY_USERNAME,REGISTRY_URL envs: REGISTRY_PASSWORD,REGISTRY_USERNAME,REGISTRY_URL
@@ -169,8 +163,8 @@ jobs:
- name: 📁 Ensure application directory structure - name: 📁 Ensure application directory structure
uses: appleboy/ssh-action@v1.0.3 uses: appleboy/ssh-action@v1.0.3
with: with:
host: ${{ secrets.PRODUCTION_HOST }} host: ${{ vars.PRODUCTION_HOST }}
username: ${{ secrets.PRODUCTION_USER }} username: ${{ vars.PRODUCTION_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }} key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 22 port: 22
script: | script: |
@@ -204,8 +198,8 @@ jobs:
- name: 📦 Copy docker-compose.prod.yml to server - name: 📦 Copy docker-compose.prod.yml to server
uses: appleboy/scp-action@v0.1.7 uses: appleboy/scp-action@v0.1.7
with: with:
host: ${{ secrets.PRODUCTION_HOST }} host: ${{ vars.PRODUCTION_HOST }}
username: ${{ secrets.PRODUCTION_USER }} username: ${{ vars.PRODUCTION_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }} key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 22 port: 22
source: "docker-compose.prod.yml" source: "docker-compose.prod.yml"
@@ -220,8 +214,8 @@ jobs:
REGISTRY_URL: ${{ env.REGISTRY }} REGISTRY_URL: ${{ env.REGISTRY }}
IMAGE_FULL: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest IMAGE_FULL: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
with: with:
host: ${{ secrets.PRODUCTION_HOST }} host: ${{ vars.PRODUCTION_HOST }}
username: ${{ secrets.PRODUCTION_USER }} username: ${{ vars.PRODUCTION_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }} key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 22 port: 22
envs: REGISTRY_PASSWORD,REGISTRY_USERNAME,REGISTRY_URL,IMAGE_FULL envs: REGISTRY_PASSWORD,REGISTRY_USERNAME,REGISTRY_URL,IMAGE_FULL
@@ -278,8 +272,8 @@ jobs:
- name: ❤️ Health check - name: ❤️ Health check
uses: appleboy/ssh-action@v1.0.3 uses: appleboy/ssh-action@v1.0.3
with: with:
host: ${{ secrets.PRODUCTION_HOST }} host: ${{ vars.PRODUCTION_HOST }}
username: ${{ secrets.PRODUCTION_USER }} username: ${{ vars.PRODUCTION_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }} key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 22 port: 22
script: | script: |

View File

@@ -101,12 +101,13 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
</div> </div>
<div className="flex flex-wrap gap-2 mb-2"> <div className="flex flex-wrap gap-2 mb-2">
{post.frontmatter.tags.map((tag: string) => ( {post.frontmatter.tags.map((tag: string) => (
<span <Link
key={tag} key={tag}
href={`/tags/${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" 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} #{tag}
</span> </Link>
))} ))}
</div> </div>
</div> </div>

View File

@@ -140,16 +140,22 @@ export default async function HomePage() {
))} ))}
</div> </div>
{allPosts.length > 6 && ( <div className="mt-12 flex gap-4 justify-center flex-wrap">
<div className="mt-12 text-center"> {allPosts.length > 6 && (
<Link <Link
href="/blog" href="/blog"
className="inline-flex items-center px-8 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-sm tracking-wider hover:bg-slate-200 dark:hover:bg-slate-800 hover:border-slate-500 dark:hover:border-slate-600 transition-colors duration-200" className="inline-flex items-center px-8 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-sm tracking-wider hover:bg-slate-200 dark:hover:bg-slate-800 hover:border-slate-500 dark:hover:border-slate-600 transition-colors duration-200"
> >
[VEZI TOATE ARTICOLELE] &gt;&gt; [VEZI TOATE ARTICOLELE] &gt;&gt;
</Link> </Link>
</div> )}
)} <Link
href="/tags"
className="inline-flex items-center px-8 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-sm tracking-wider hover:bg-slate-200 dark:hover:bg-slate-800 hover:border-slate-500 dark:hover:border-slate-600 transition-colors duration-200"
>
[VEZI TOATE TAG-URILE] &gt;&gt;
</Link>
</div>
</div> </div>
</section> </section>

View File

@@ -0,0 +1,40 @@
import Link from 'next/link';
export default function TagNotFound() {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center">
<div className="max-w-2xl mx-auto px-6">
<div className="border-4 border-red-900 bg-red-950 p-12 text-center">
<div className="border-2 border-red-800 bg-red-900 p-4 mb-6 inline-block">
<p className="font-mono text-6xl font-bold text-red-400">404</p>
</div>
<p className="font-mono text-xs text-red-600 uppercase tracking-widest mb-2">
ERROR: TAG NOT FOUND
</p>
<h1 className="font-mono text-3xl font-bold uppercase mb-4 text-red-400">
TAG DOES NOT EXIST
</h1>
<p className="font-mono text-sm text-red-300 mb-8 max-w-md mx-auto">
&gt; THE REQUESTED TAG COULD NOT BE LOCATED IN THE DATABASE
<br />
&gt; IT MAY HAVE BEEN REMOVED OR NEVER EXISTED
</p>
<div className="flex gap-4 justify-center flex-wrap">
<Link
href="/tags"
className="inline-block px-6 py-3 font-mono text-xs uppercase border-2 border-cyan-400 bg-cyan-900 text-cyan-100 hover:bg-cyan-800 transition"
>
&gt; VIEW ALL TAGS
</Link>
<Link
href="/blog"
className="inline-block px-6 py-3 font-mono text-xs uppercase border-2 border-slate-600 bg-slate-900 text-slate-300 hover:bg-slate-800 transition"
>
&gt; VIEW ALL POSTS
</Link>
</div>
</div>
</div>
</div>
);
}

203
app/tags/[tag]/page.tsx Normal file
View File

@@ -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<Metadata> {
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 (
<article className="border-2 border-slate-700 bg-slate-900 p-6 hover:border-cyan-400 transition">
{post.frontmatter.image && (
<img
src={post.frontmatter.image}
alt={post.frontmatter.title}
className="w-full h-48 object-cover mb-4 border-2 border-slate-800"
/>
)}
<div className="flex items-center gap-2 font-mono text-xs text-zinc-500 mb-3 uppercase">
<time dateTime={post.frontmatter.date}>
{formatDate(post.frontmatter.date)}
</time>
<span>&gt;</span>
<span>{post.readingTime} min</span>
</div>
<h2 className="font-mono text-lg font-bold mb-3 uppercase">
<Link
href={`/blog/${post.slug}`}
className="text-cyan-400 hover:text-cyan-300 transition"
>
{post.frontmatter.title}
</Link>
</h2>
<p className="text-zinc-400 mb-4 line-clamp-3">
{post.frontmatter.description}
</p>
{post.frontmatter.tags && (
<TagList tags={post.frontmatter.tags} variant="minimal" />
)}
</article>
);
}
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 (
<div className="min-h-screen bg-slate-950 text-slate-100">
<div className="max-w-6xl mx-auto px-6 py-12">
<div className="border-4 border-cyan-800 bg-cyan-950 p-8 mb-8">
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<p className="font-mono text-xs text-cyan-600 uppercase tracking-widest mb-2">
TAG ARCHIVE
</p>
<h1 className="font-mono text-3xl font-bold uppercase mb-2">
<span className="text-cyan-400">#{tagInfo.name}</span>
</h1>
<p className="font-mono text-sm text-cyan-300">
&gt; {tagInfo.count} {tagInfo.count === 1 ? 'DOCUMENT' : 'DOCUMENTS'}
</p>
</div>
<div>
<Link
href="/tags"
className="inline-flex items-center px-4 py-2 font-mono text-xs uppercase border-2 border-cyan-400 bg-slate-900 text-cyan-400 hover:bg-cyan-900 transition"
>
&gt; ALL TAGS
</Link>
</div>
</div>
</div>
<div className="grid gap-8 lg:grid-cols-4">
<div className="lg:col-span-3">
{posts.length === 0 ? (
<div className="border-4 border-slate-800 bg-slate-900 p-12 text-center">
<p className="font-mono text-zinc-400 mb-6 uppercase">
&gt; NO DOCUMENTS FOUND
</p>
<Link
href="/blog"
className="inline-block px-6 py-3 font-mono text-sm uppercase border-2 border-cyan-400 bg-cyan-900 text-cyan-100 hover:bg-cyan-800 transition"
>
&gt; VIEW ALL POSTS
</Link>
</div>
) : (
<div className="grid gap-6">
{posts.map(post => (
<PostCard key={post.slug} post={post} />
))}
</div>
)}
</div>
<aside className="lg:col-span-1 space-y-6">
{relatedTags.length > 0 && (
<div className="border-2 border-slate-700 bg-slate-900 p-6">
<div className="border-b-2 border-slate-700 pb-3 mb-4">
<h2 className="font-mono text-sm font-bold uppercase text-cyan-400">
RELATED TAGS
</h2>
</div>
<div className="space-y-2">
{relatedTags.map(tag => (
<Link
key={tag.slug}
href={`/tags/${tag.slug}`}
className="flex items-center justify-between p-2 border border-slate-700 hover:border-cyan-400 hover:bg-slate-800 transition"
>
<span className="font-mono text-xs uppercase text-zinc-300">
#{tag.name}
</span>
<span className="font-mono text-xs text-zinc-500">
[{tag.count}]
</span>
</Link>
))}
</div>
</div>
)}
<div className="border-2 border-slate-700 bg-slate-900 p-6">
<div className="border-b-2 border-slate-700 pb-3 mb-4">
<h2 className="font-mono text-sm font-bold uppercase text-cyan-400">
QUICK NAV
</h2>
</div>
<div className="space-y-2">
<Link
href="/blog"
className="block p-2 border border-slate-700 hover:border-cyan-400 hover:bg-slate-800 transition font-mono text-xs uppercase"
>
&gt; ALL POSTS
</Link>
<Link
href="/tags"
className="block p-2 border border-slate-700 hover:border-cyan-400 hover:bg-slate-800 transition font-mono text-xs uppercase"
>
&gt; ALL TAGS
</Link>
<Link
href="/"
className="block p-2 border border-slate-700 hover:border-cyan-400 hover:bg-slate-800 transition font-mono text-xs uppercase"
>
&gt; HOME
</Link>
</div>
</div>
</aside>
</div>
</div>
</div>
);
}

16
app/tags/layout.tsx Normal file
View File

@@ -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 (
<>
<Navbar />
{children}
</>
)
}

146
app/tags/page.tsx Normal file
View File

@@ -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 (
<div className="min-h-screen bg-slate-950 text-slate-100">
<div className="max-w-6xl mx-auto px-6 py-12">
<div className="border-4 border-slate-800 bg-slate-900 p-12 text-center">
<h1 className="font-mono text-3xl font-bold uppercase mb-4 text-cyan-400">
TAG DATABASE
</h1>
<p className="font-mono text-zinc-400 mb-8">
&gt; NO TAGS AVAILABLE
</p>
<Link
href="/blog"
className="inline-block px-6 py-3 font-mono text-sm uppercase border-2 border-cyan-400 bg-cyan-900 text-cyan-100 hover:bg-cyan-800 transition"
>
&gt; VIEW ALL POSTS
</Link>
</div>
</div>
</div>
);
}
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<string, typeof allTags>);
const sortedLetters = Object.keys(groupedTags).sort();
return (
<div className="min-h-screen bg-slate-950 text-slate-100">
<div className="max-w-6xl mx-auto px-6 py-12">
<div className="border-4 border-slate-800 bg-slate-900 p-8 mb-8">
<p className="font-mono text-xs text-zinc-500 uppercase tracking-widest mb-2">
DOCUMENT TYPE: TAG DATABASE
</p>
<h1 className="font-mono text-4xl font-bold uppercase text-cyan-400 mb-4">
TAG REGISTRY
</h1>
<p className="font-mono text-lg text-zinc-400">
&gt; TOTAL TAGS: {allTags.length}
</p>
</div>
<section className="mb-12">
<div className="border-2 border-slate-700 bg-slate-900 p-6 mb-2">
<p className="font-mono text-xs text-zinc-500 uppercase tracking-widest mb-4">
SECTION: TAG CLOUD VISUALIZATION
</p>
</div>
<div className="border-2 border-slate-700 bg-zinc-950 p-8">
<TagCloud tags={tagCloud} />
</div>
</section>
<section className="mb-12">
<div className="border-2 border-slate-700 bg-slate-900 p-6 mb-2">
<p className="font-mono text-xs text-zinc-500 uppercase tracking-widest">
SECTION: ALPHABETICAL INDEX
</p>
</div>
<div className="space-y-8">
{sortedLetters.map(letter => (
<div key={letter}>
<div className="border-2 border-cyan-700 bg-cyan-950 p-4 mb-4">
<h3 className="font-mono text-xl font-bold text-cyan-400 uppercase">
&gt; [{letter}]
</h3>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{groupedTags[letter].map(tag => (
<Link
key={tag.slug}
href={`/tags/${tag.slug}`}
className="flex items-center justify-between p-4 border-2 border-slate-700 bg-slate-900 hover:border-cyan-400 hover:bg-slate-800 transition"
>
<span className="font-mono text-sm uppercase">#{tag.name}</span>
<TagBadge count={tag.count} />
</Link>
))}
</div>
</div>
))}
</div>
</section>
<section className="border-4 border-cyan-800 bg-cyan-950 p-8">
<div className="mb-6">
<p className="font-mono text-xs text-cyan-600 uppercase tracking-widest mb-2">
DOCUMENT STATISTICS
</p>
<h2 className="font-mono text-2xl font-bold uppercase text-cyan-400">
TAG METRICS
</h2>
</div>
<div className="grid gap-6 sm:grid-cols-3">
<div className="border-2 border-cyan-700 bg-slate-900 p-6 text-center">
<div className="font-mono text-3xl font-bold text-cyan-400">
{allTags.length}
</div>
<div className="font-mono text-xs text-zinc-400 uppercase mt-2">
TOTAL TAGS
</div>
</div>
<div className="border-2 border-cyan-700 bg-slate-900 p-6 text-center">
<div className="font-mono text-3xl font-bold text-cyan-400">
{Math.max(...allTags.map(t => t.count))}
</div>
<div className="font-mono text-xs text-zinc-400 uppercase mt-2">
MAX POSTS/TAG
</div>
</div>
<div className="border-2 border-cyan-700 bg-slate-900 p-6 text-center">
<div className="font-mono text-3xl font-bold text-cyan-400">
{Math.round(allTags.reduce((sum, t) => sum + t.count, 0) / allTags.length)}
</div>
<div className="font-mono text-xs text-zinc-400 uppercase mt-2">
AVG POSTS/TAG
</div>
</div>
</div>
</section>
</div>
</div>
);
}

View File

@@ -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 (
<div className="border-2 border-slate-700 bg-slate-900 p-6">
<div className="border-b-2 border-slate-700 pb-3 mb-4">
<h3 className="font-mono text-sm font-bold uppercase text-cyan-400">
POPULAR TAGS
</h3>
</div>
<div className="space-y-3">
{tags.map((tag, index) => (
<Link
key={tag.slug}
href={`/tags/${tag.slug}`}
className="flex items-center justify-between group p-2 border border-slate-700 hover:border-cyan-400 hover:bg-slate-800 transition"
>
<div className="flex items-center space-x-3">
<span className="font-mono text-xs text-zinc-500">
[{index + 1}]
</span>
<span className="font-mono text-xs uppercase group-hover:text-cyan-400 transition">
#{tag.name}
</span>
</div>
<TagBadge count={tag.count} />
</Link>
))}
</div>
<Link
href="/tags"
className="block mt-4 text-center font-mono text-xs text-cyan-400 hover:text-cyan-300 transition uppercase"
>
&gt; VIEW ALL TAGS
</Link>
</div>
);
}

View File

@@ -0,0 +1,20 @@
interface TagBadgeProps {
count: number;
className?: string;
}
export function TagBadge({ count, className = '' }: TagBadgeProps) {
return (
<span
className={`
inline-flex items-center justify-center
px-2 py-1 font-mono text-xs font-bold
bg-cyan-900 border border-cyan-700
text-cyan-300
${className}
`}
>
{count}
</span>
);
}

View File

@@ -0,0 +1,36 @@
import Link from 'next/link';
import { TagInfo } from '@/lib/tags';
interface TagCloudProps {
tags: Array<TagInfo & { size: 'sm' | 'md' | 'lg' | 'xl' }>;
}
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 (
<div className="flex flex-wrap gap-4 items-baseline">
{tags.map(tag => (
<Link
key={tag.slug}
href={`/tags/${tag.slug}`}
className={`
${sizeClasses[tag.size]}
font-mono uppercase
text-zinc-400
hover:text-cyan-400
transition-colors
`}
title={`${tag.count} ${tag.count === 1 ? 'articol' : 'articole'}`}
>
#{tag.name}
</Link>
))}
</div>
);
}

View File

@@ -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 (
<div className={`flex flex-wrap gap-2 ${className}`}>
{validTags.map(tag => (
<Link
key={tag}
href={`/tags/${slugifyTag(tag)}`}
className={`${baseClasses} ${variants[variant]}`}
>
<span className="mr-1">#</span>
{tag}
</Link>
))}
</div>
);
}

View File

@@ -67,14 +67,14 @@ services:
# Resource limits for production # Resource limits for production
# Prevents container from consuming all server resources # Prevents container from consuming all server resources
deploy: # deploy:
resources: # resources:
limits: # limits:
cpus: '1.0' # Maximum 1 CPU core # cpus: '1.0' # Maximum 1 CPU core
memory: 512M # Maximum 512MB RAM # memory: 512M # Maximum 512MB RAM
reservations: # reservations:
cpus: '0.25' # Reserve at least 0.25 CPU cores # cpus: '0.25' # Reserve at least 0.25 CPU cores
memory: 256M # Reserve at least 256MB RAM # memory: 256M # Reserve at least 256MB RAM
# Network configuration # Network configuration
networks: networks:

131
lib/tags.ts Normal file
View File

@@ -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<TagInfo[]> {
const posts = getAllPosts();
const tagMap = new Map<string, number>();
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<Post[]> {
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<TagInfo | null> {
const allTags = await getAllTags();
return allTags.find(tag => tag.slug === tagSlug) || null;
}
export async function getPopularTags(limit = 10): Promise<TagInfo[]> {
const allTags = await getAllTags();
return allTags.slice(0, limit);
}
export async function getRelatedTags(tagSlug: string, limit = 5): Promise<TagInfo[]> {
const posts = await getPostsByTag(tagSlug);
const relatedTagMap = new Map<string, number>();
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<Array<TagInfo & { size: 'sm' | 'md' | 'lg' | 'xl' }>> {
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 };
});
}

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"dev": "next dev -p 3030", "dev": "next dev -p 3030",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start -p 3030",
"lint": "eslint . --ext .ts,.tsx,.js,.jsx", "lint": "eslint . --ext .ts,.tsx,.js,.jsx",
"lint:fix": "eslint . --ext .ts,.tsx,.js,.jsx --fix", "lint:fix": "eslint . --ext .ts,.tsx,.js,.jsx --fix",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"", "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"",