Compare commits
2 Commits
ec37c33afa
...
5be30eb8c4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5be30eb8c4 | ||
|
|
3136131182 |
@@ -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: |
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
14
app/page.tsx
14
app/page.tsx
@@ -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] >>
|
[VEZI TOATE ARTICOLELE] >>
|
||||||
</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] >>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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
|
# 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
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" />
|
||||||
/// <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.
|
||||||
|
|||||||
@@ -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}\"",
|
||||||
|
|||||||
Reference in New Issue
Block a user