feat: ajout structure projet + config Node 20
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
type MarkdownRendererProps = {
|
||||
content: string;
|
||||
};
|
||||
|
||||
export function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
||||
const renderMarkdown = (md: string): string => {
|
||||
const lines = md.split("\n");
|
||||
const result: string[] = [];
|
||||
let inCodeBlock = false;
|
||||
let codeBlockLang = "";
|
||||
let codeBlockContent: string[] = [];
|
||||
let inList = false;
|
||||
let listType: "ul" | "ol" | null = null;
|
||||
|
||||
const escapeHtml = (text: string): string => {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
};
|
||||
|
||||
const processInline = (text: string): string => {
|
||||
let processed = text;
|
||||
|
||||
// Inline code (avant les autres pour éviter les conflits)
|
||||
processed = processed.replace(
|
||||
/`([^`]+)`/g,
|
||||
'<code class="bg-slate-800 px-1.5 py-0.5 rounded text-cyan-400 text-sm font-mono">$1</code>'
|
||||
);
|
||||
|
||||
// Bold
|
||||
processed = processed.replace(
|
||||
/\*\*([^*]+)\*\*/g,
|
||||
'<strong class="font-semibold text-slate-100">$1</strong>'
|
||||
);
|
||||
|
||||
// Italic
|
||||
processed = processed.replace(
|
||||
/\*([^*]+)\*/g,
|
||||
'<em class="italic">$1</em>'
|
||||
);
|
||||
|
||||
// Links
|
||||
processed = processed.replace(
|
||||
/\[([^\]]+)\]\(([^)]+)\)/g,
|
||||
'<a href="$2" class="text-cyan-400 hover:text-cyan-300 underline underline-offset-2" target="_blank" rel="noopener noreferrer">$1</a>'
|
||||
);
|
||||
|
||||
// Images
|
||||
processed = processed.replace(
|
||||
/!\[([^\]]*)\]\(([^)]+)\)/g,
|
||||
'<img src="$2" alt="$1" class="rounded-lg my-4 max-w-full" />'
|
||||
);
|
||||
|
||||
return processed;
|
||||
};
|
||||
|
||||
const closeList = () => {
|
||||
if (inList && listType) {
|
||||
result.push(listType === "ul" ? "</ul>" : "</ol>");
|
||||
inList = false;
|
||||
listType = null;
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Code block start/end
|
||||
if (line.trim().startsWith("```")) {
|
||||
if (!inCodeBlock) {
|
||||
closeList();
|
||||
inCodeBlock = true;
|
||||
codeBlockLang = line.trim().slice(3).trim() || "text";
|
||||
codeBlockContent = [];
|
||||
} else {
|
||||
// End code block
|
||||
const code = escapeHtml(codeBlockContent.join("\n"));
|
||||
result.push(`
|
||||
<div class="relative my-6">
|
||||
<div class="absolute top-0 right-0 px-3 py-1 text-xs font-mono text-slate-500 bg-slate-800/80 rounded-bl-lg rounded-tr-lg">${codeBlockLang}</div>
|
||||
<pre class="bg-slate-900 border border-slate-700/50 rounded-lg p-4 overflow-x-auto"><code class="text-sm font-mono text-emerald-400 leading-relaxed">${code}</code></pre>
|
||||
</div>
|
||||
`);
|
||||
inCodeBlock = false;
|
||||
codeBlockLang = "";
|
||||
codeBlockContent = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inside code block
|
||||
if (inCodeBlock) {
|
||||
codeBlockContent.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Empty line
|
||||
if (!trimmedLine) {
|
||||
closeList();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Headers
|
||||
if (trimmedLine.startsWith("#### ")) {
|
||||
closeList();
|
||||
result.push(
|
||||
`<h4 class="text-base font-semibold text-slate-100 mt-6 mb-2">${processInline(
|
||||
escapeHtml(trimmedLine.slice(5))
|
||||
)}</h4>`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (trimmedLine.startsWith("### ")) {
|
||||
closeList();
|
||||
result.push(
|
||||
`<h3 class="text-lg font-semibold text-slate-100 mt-8 mb-3">${processInline(
|
||||
escapeHtml(trimmedLine.slice(4))
|
||||
)}</h3>`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (trimmedLine.startsWith("## ")) {
|
||||
closeList();
|
||||
result.push(
|
||||
`<h2 class="text-xl font-semibold text-slate-100 mt-10 mb-4 pb-2 border-b border-slate-700/50">${processInline(
|
||||
escapeHtml(trimmedLine.slice(3))
|
||||
)}</h2>`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (trimmedLine.startsWith("# ")) {
|
||||
closeList();
|
||||
result.push(
|
||||
`<h1 class="text-2xl font-bold text-slate-100 mt-10 mb-6">${processInline(
|
||||
escapeHtml(trimmedLine.slice(2))
|
||||
)}</h1>`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (
|
||||
trimmedLine === "---" ||
|
||||
trimmedLine === "***" ||
|
||||
trimmedLine === "___"
|
||||
) {
|
||||
closeList();
|
||||
result.push('<hr class="my-8 border-slate-700/50" />');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
if (trimmedLine.startsWith("> ")) {
|
||||
closeList();
|
||||
result.push(
|
||||
`<blockquote class="border-l-4 border-cyan-500/50 pl-4 my-4 text-slate-400 italic">${processInline(
|
||||
escapeHtml(trimmedLine.slice(2))
|
||||
)}</blockquote>`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
if (trimmedLine.startsWith("- ") || trimmedLine.startsWith("* ")) {
|
||||
if (!inList || listType !== "ul") {
|
||||
closeList();
|
||||
result.push('<ul class="my-4 space-y-1">');
|
||||
inList = true;
|
||||
listType = "ul";
|
||||
}
|
||||
result.push(
|
||||
`<li class="ml-6 list-disc text-slate-300">${processInline(
|
||||
escapeHtml(trimmedLine.slice(2))
|
||||
)}</li>`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
const orderedMatch = trimmedLine.match(/^(\d+)\.\s+(.+)$/);
|
||||
if (orderedMatch) {
|
||||
if (!inList || listType !== "ol") {
|
||||
closeList();
|
||||
result.push('<ol class="my-4 space-y-1">');
|
||||
inList = true;
|
||||
listType = "ol";
|
||||
}
|
||||
result.push(
|
||||
`<li class="ml-6 list-decimal text-slate-300">${processInline(
|
||||
escapeHtml(orderedMatch[2])
|
||||
)}</li>`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular paragraph
|
||||
closeList();
|
||||
result.push(
|
||||
`<p class="text-slate-300 my-4 leading-relaxed">${processInline(
|
||||
escapeHtml(trimmedLine)
|
||||
)}</p>`
|
||||
);
|
||||
}
|
||||
|
||||
// Close any remaining list
|
||||
closeList();
|
||||
|
||||
// Close any unclosed code block
|
||||
if (inCodeBlock && codeBlockContent.length > 0) {
|
||||
const code = escapeHtml(codeBlockContent.join("\n"));
|
||||
result.push(`
|
||||
<div class="relative my-6">
|
||||
<div class="absolute top-0 right-0 px-3 py-1 text-xs font-mono text-slate-500 bg-slate-800/80 rounded-bl-lg rounded-tr-lg">${codeBlockLang}</div>
|
||||
<pre class="bg-slate-900 border border-slate-700/50 rounded-lg p-4 overflow-x-auto"><code class="text-sm font-mono text-emerald-400 leading-relaxed">${code}</code></pre>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
return result.join("\n");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="prose prose-invert max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { BackButton } from "@/components/ui";
|
||||
import { getTutorialBySlug } from "@/lib/tutorials";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { MarkdownRenderer } from "./markdown-renderer";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ slug: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const tutorial = await getTutorialBySlug(slug);
|
||||
|
||||
if (!tutorial) {
|
||||
return { title: "Tutoriel non trouvé" };
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${tutorial.title} | Jessy David`,
|
||||
description: tutorial.excerpt,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TutorialPage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
const tutorial = await getTutorialBySlug(slug);
|
||||
|
||||
if (!tutorial) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const difficultyColors = {
|
||||
Débutant: "text-emerald-400 border-emerald-500/40",
|
||||
Intermédiaire: "text-amber-400 border-amber-500/40",
|
||||
Avancé: "text-rose-400 border-rose-500/40",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-100 selection:bg-cyan-500/30">
|
||||
{/* Effets de fond */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-96 h-96 bg-cyan-500/5 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-violet-500/5 rounded-full blur-3xl" />
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.015]"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Contenu */}
|
||||
<div className="relative z-10 px-6 py-12">
|
||||
<article className="max-w-3xl mx-auto">
|
||||
{/* Navigation retour */}
|
||||
<BackButton />
|
||||
|
||||
{/* Header */}
|
||||
<header className="mb-8 pb-6 border-b border-slate-700/50">
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
<span
|
||||
className="px-2 py-0.5 text-xs font-mono rounded border bg-slate-800/60"
|
||||
style={{
|
||||
borderColor: `${tutorial.category.color}40`,
|
||||
color: tutorial.category.color,
|
||||
}}
|
||||
>
|
||||
{tutorial.category.name}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs font-mono rounded border bg-slate-800/60 ${
|
||||
difficultyColors[tutorial.difficulty]
|
||||
}`}
|
||||
>
|
||||
{tutorial.difficulty}
|
||||
</span>
|
||||
<span className="text-xs font-mono text-slate-500">
|
||||
{tutorial.readTime}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-4">
|
||||
<span className="bg-linear-to-r from-cyan-400 via-violet-400 to-fuchsia-400 bg-clip-text text-transparent">
|
||||
{tutorial.title}
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-slate-400">{tutorial.excerpt}</p>
|
||||
|
||||
<div className="mt-4 text-xs font-mono text-slate-500">
|
||||
Publié le{" "}
|
||||
{new Date(tutorial.date).toLocaleDateString(
|
||||
"fr-FR",
|
||||
{
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Contenu Markdown */}
|
||||
<MarkdownRenderer content={tutorial.content} />
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user