diff --git a/.gitea/workflows/main.yml b/.gitea/workflows/main.yml
index a53a079..adbb975 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
@@ -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: |
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 (
+
+
+
+
+
+ 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.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/docker-compose.prod.yml b/docker-compose.prod.yml
index 48cb140..6ac4caa 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -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:
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}\"",