🏷️ added tags system and workflows #6
@@ -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
|
||||
|
||||
@@ -102,11 +96,11 @@ jobs:
|
||||
# - Uses Dockerfile.nextjs from project root
|
||||
# - Tags image with both 'latest' and commit SHA
|
||||
# - Enables inline cache for faster subsequent builds
|
||||
# -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} ❗ do this if deploying on PR creation
|
||||
docker build \
|
||||
--progress=plain \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
|
||||
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
|
||||
-f Dockerfile.nextjs \
|
||||
.
|
||||
|
||||
@@ -120,11 +114,11 @@ jobs:
|
||||
|
||||
# Push both tags (latest and commit SHA)
|
||||
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 " - ${{ 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
|
||||
@@ -135,7 +129,7 @@ jobs:
|
||||
needs: [build-and-push] # Wait for build job to complete
|
||||
environment:
|
||||
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:
|
||||
- name: 🔎 Checkout code (for docker-compose file)
|
||||
@@ -148,8 +142,8 @@ jobs:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_URL: ${{ env.REGISTRY }}
|
||||
with:
|
||||
host: ${{ secrets.PRODUCTION_HOST }}
|
||||
username: ${{ secrets.PRODUCTION_USER }}
|
||||
host: ${{ vars.PRODUCTION_HOST }}
|
||||
username: ${{ vars.PRODUCTION_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
port: 22
|
||||
envs: REGISTRY_PASSWORD,REGISTRY_USERNAME,REGISTRY_URL
|
||||
@@ -169,8 +163,8 @@ jobs:
|
||||
- name: 📁 Ensure application directory structure
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.PRODUCTION_HOST }}
|
||||
username: ${{ secrets.PRODUCTION_USER }}
|
||||
host: ${{ vars.PRODUCTION_HOST }}
|
||||
username: ${{ vars.PRODUCTION_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
port: 22
|
||||
script: |
|
||||
@@ -204,8 +198,8 @@ jobs:
|
||||
- name: 📦 Copy docker-compose.prod.yml to server
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.PRODUCTION_HOST }}
|
||||
username: ${{ secrets.PRODUCTION_USER }}
|
||||
host: ${{ vars.PRODUCTION_HOST }}
|
||||
username: ${{ vars.PRODUCTION_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
port: 22
|
||||
source: "docker-compose.prod.yml"
|
||||
@@ -220,8 +214,8 @@ jobs:
|
||||
REGISTRY_URL: ${{ env.REGISTRY }}
|
||||
IMAGE_FULL: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
with:
|
||||
host: ${{ secrets.PRODUCTION_HOST }}
|
||||
username: ${{ secrets.PRODUCTION_USER }}
|
||||
host: ${{ vars.PRODUCTION_HOST }}
|
||||
username: ${{ vars.PRODUCTION_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
port: 22
|
||||
envs: REGISTRY_PASSWORD,REGISTRY_USERNAME,REGISTRY_URL,IMAGE_FULL
|
||||
@@ -278,8 +272,8 @@ jobs:
|
||||
- name: ❤️ Health check
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.PRODUCTION_HOST }}
|
||||
username: ${{ secrets.PRODUCTION_USER }}
|
||||
host: ${{ vars.PRODUCTION_HOST }}
|
||||
username: ${{ vars.PRODUCTION_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
port: 22
|
||||
script: |
|
||||
|
||||
@@ -101,12 +101,13 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{post.frontmatter.tags.map((tag: string) => (
|
||||
<span
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
10
app/page.tsx
10
app/page.tsx
@@ -140,16 +140,22 @@ export default async function HomePage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex gap-4 justify-center flex-wrap">
|
||||
{allPosts.length > 6 && (
|
||||
<div className="mt-12 text-center">
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
[VEZI TOATE ARTICOLELE] >>
|
||||
</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] >>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
40
app/tags/[tag]/not-found.tsx
Normal file
40
app/tags/[tag]/not-found.tsx
Normal 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">
|
||||
> THE REQUESTED TAG COULD NOT BE LOCATED IN THE DATABASE
|
||||
<br />
|
||||
> 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"
|
||||
>
|
||||
> 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"
|
||||
>
|
||||
> VIEW ALL POSTS
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
203
app/tags/[tag]/page.tsx
Normal file
203
app/tags/[tag]/page.tsx
Normal 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>></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">
|
||||
> {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"
|
||||
>
|
||||
> 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">
|
||||
> 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"
|
||||
>
|
||||
> 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"
|
||||
>
|
||||
> 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"
|
||||
>
|
||||
> 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"
|
||||
>
|
||||
> HOME
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
app/tags/layout.tsx
Normal file
16
app/tags/layout.tsx
Normal 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
146
app/tags/page.tsx
Normal 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">
|
||||
> 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"
|
||||
>
|
||||
> 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">
|
||||
> 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">
|
||||
> [{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>
|
||||
);
|
||||
}
|
||||
44
components/blog/popular-tags.tsx
Normal file
44
components/blog/popular-tags.tsx
Normal 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"
|
||||
>
|
||||
> VIEW ALL TAGS
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
components/blog/tag-badge.tsx
Normal file
20
components/blog/tag-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
components/blog/tag-cloud.tsx
Normal file
36
components/blog/tag-cloud.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
components/blog/tag-list.tsx
Normal file
37
components/blog/tag-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -67,14 +67,14 @@ services:
|
||||
|
||||
# Resource limits for production
|
||||
# Prevents container from consuming all server resources
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1.0' # Maximum 1 CPU core
|
||||
memory: 512M # Maximum 512MB RAM
|
||||
reservations:
|
||||
cpus: '0.25' # Reserve at least 0.25 CPU cores
|
||||
memory: 256M # Reserve at least 256MB RAM
|
||||
# deploy:
|
||||
# resources:
|
||||
# limits:
|
||||
# cpus: '1.0' # Maximum 1 CPU core
|
||||
# memory: 512M # Maximum 512MB RAM
|
||||
# reservations:
|
||||
# cpus: '0.25' # Reserve at least 0.25 CPU cores
|
||||
# memory: 256M # Reserve at least 256MB RAM
|
||||
|
||||
# Network configuration
|
||||
networks:
|
||||
|
||||
131
lib/tags.ts
Normal file
131
lib/tags.ts
Normal 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
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <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
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
Reference in New Issue
Block a user