feat: ajout structure projet + config Node 20
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Book1Outlined,
|
||||
Folder1Outlined,
|
||||
PlusOutlined,
|
||||
} from "@lineiconshq/free-icons";
|
||||
import { Lineicons } from "@lineiconshq/react-lineicons";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
href: "/dashboard",
|
||||
label: "Tutoriels",
|
||||
icon: Book1Outlined,
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
href: "/dashboard/nouveau",
|
||||
label: "Nouveau",
|
||||
icon: PlusOutlined,
|
||||
},
|
||||
{
|
||||
href: "/dashboard/categories",
|
||||
label: "Catégories",
|
||||
icon: Folder1Outlined,
|
||||
},
|
||||
];
|
||||
|
||||
export function DashboardSidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isActive = (href: string, exact?: boolean) => {
|
||||
if (exact) return pathname === href;
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="w-64 min-h-[calc(100vh-65px)] border-r border-slate-800/50 bg-slate-900/30 backdrop-blur-sm sticky top-16">
|
||||
<div className="p-4">
|
||||
{/* Section title */}
|
||||
<div className="px-4 py-2 mb-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Gestion
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<nav className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const active = isActive(item.href, item.exact);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`group flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
active
|
||||
? "bg-cyan-500/10 text-cyan-400 border-l-2 border-cyan-400 ml-[-1px]"
|
||||
: "text-slate-400 hover:text-slate-100 hover:bg-slate-800/50"
|
||||
}`}
|
||||
>
|
||||
<Lineicons
|
||||
icon={item.icon}
|
||||
size={20}
|
||||
className={`transition-transform duration-200 ${
|
||||
active ? "" : "group-hover:scale-110"
|
||||
}`}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
{active && (
|
||||
<span className="ml-auto w-1.5 h-1.5 rounded-full bg-cyan-400" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="my-6 border-t border-slate-800/50" />
|
||||
|
||||
{/* Quick action */}
|
||||
<Link
|
||||
href="/dashboard/nouveau"
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 rounded-lg text-sm font-medium bg-gradient-to-r from-cyan-500/20 to-violet-500/20 text-slate-100 hover:from-cyan-500/30 hover:to-violet-500/30 border border-slate-700/50 hover:border-cyan-500/30 transition-all duration-300"
|
||||
>
|
||||
<Lineicons icon={PlusOutlined} size={18} />
|
||||
<span>Créer un tuto</span>
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
"use client";
|
||||
|
||||
import { MarkdownRenderer } from "@/app/tutos/[slug]/markdown-renderer";
|
||||
import type { Category, Tutorial } from "@/lib/tutorials";
|
||||
import {
|
||||
AlignTextLeftOutlined,
|
||||
Books2Outlined,
|
||||
CheckCircle1Outlined,
|
||||
Code1Outlined,
|
||||
EyeOutlined,
|
||||
Folder1Outlined,
|
||||
KeyboardOutlined,
|
||||
Layers1Outlined,
|
||||
Pencil1Outlined,
|
||||
StopwatchOutlined,
|
||||
} from "@lineiconshq/free-icons";
|
||||
import { Lineicons } from "@lineiconshq/react-lineicons";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
type TutorialFormProps = {
|
||||
categories: Category[];
|
||||
action: (formData: FormData) => Promise<void>;
|
||||
submitLabel: string;
|
||||
initialData?: Partial<Tutorial>;
|
||||
};
|
||||
|
||||
// Wrapper pour simplifier l'utilisation des icônes LineIcons
|
||||
function Icon({
|
||||
icon,
|
||||
size = 16,
|
||||
className = "",
|
||||
}: {
|
||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return <Lineicons icon={icon} size={size} className={className} />;
|
||||
}
|
||||
|
||||
// Composant pour les labels avec icône
|
||||
function FormLabel({
|
||||
icon,
|
||||
children,
|
||||
required,
|
||||
}: {
|
||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
children: React.ReactNode;
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center gap-2 text-sm font-mono text-slate-400 mb-2">
|
||||
<Icon icon={icon} size={16} className="text-cyan-500/70" />
|
||||
<span>{children}</span>
|
||||
{required && <span className="text-cyan-500">*</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// Composant input stylisé
|
||||
function FormInput({
|
||||
className = "",
|
||||
...props
|
||||
}: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
className={`w-full rounded-lg bg-slate-950/80 border border-slate-700/50 px-4 py-2.5 text-sm
|
||||
text-slate-100 placeholder:text-slate-600
|
||||
focus:outline-none focus:ring-2 focus:ring-cyan-500/30 focus:border-cyan-500/50
|
||||
hover:border-slate-600 transition-all duration-200
|
||||
backdrop-blur-sm ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Composant select stylisé
|
||||
function FormSelect({
|
||||
children,
|
||||
className = "",
|
||||
...props
|
||||
}: React.SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
<select
|
||||
{...props}
|
||||
className={`w-full rounded-lg bg-slate-950/80 border border-slate-700/50 px-4 py-2.5 text-sm
|
||||
text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500/30 focus:border-cyan-500/50
|
||||
hover:border-slate-600 transition-all duration-200
|
||||
backdrop-blur-sm cursor-pointer ${className}`}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
// Composant textarea stylisé
|
||||
function FormTextarea({
|
||||
className = "",
|
||||
...props
|
||||
}: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
return (
|
||||
<textarea
|
||||
{...props}
|
||||
className={`w-full rounded-lg bg-slate-950/80 border border-slate-700/50 px-4 py-3 text-sm
|
||||
text-slate-100 placeholder:text-slate-600
|
||||
focus:outline-none focus:ring-2 focus:ring-cyan-500/30 focus:border-cyan-500/50
|
||||
hover:border-slate-600 transition-all duration-200
|
||||
backdrop-blur-sm resize-y ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Composant carte section
|
||||
function FormSection({
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
className = "",
|
||||
noPadding = false,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
noPadding?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`relative bg-gradient-to-br from-slate-900/90 to-slate-900/70
|
||||
border border-slate-700/40 rounded-xl overflow-hidden
|
||||
backdrop-blur-md shadow-xl shadow-black/20 ${className}`}
|
||||
>
|
||||
{/* Effet de brillance en haut */}
|
||||
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-cyan-500/50 to-transparent" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-6 py-4 border-b border-slate-700/40 bg-slate-800/30">
|
||||
<div className="p-2 rounded-lg bg-cyan-500/10 border border-cyan-500/20">
|
||||
<Icon icon={icon} size={16} className="text-cyan-400" />
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-slate-100 tracking-wide">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{noPadding ? children : <div className="p-6">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TutorialForm({
|
||||
categories,
|
||||
action,
|
||||
submitLabel,
|
||||
initialData,
|
||||
}: TutorialFormProps) {
|
||||
const [content, setContent] = useState(initialData?.content || "");
|
||||
const [activeTab, setActiveTab] = useState<"edit" | "preview">("edit");
|
||||
const [charCount, setCharCount] = useState(content.length);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Mise à jour du compteur de caractères
|
||||
useEffect(() => {
|
||||
setCharCount(content.length);
|
||||
}, [content]);
|
||||
|
||||
// Estimation du temps de lecture
|
||||
const estimatedReadTime = useCallback(() => {
|
||||
const words = content.trim().split(/\s+/).length;
|
||||
const minutes = Math.ceil(words / 200);
|
||||
return `~${minutes} min`;
|
||||
}, [content]);
|
||||
|
||||
// Raccourcis clavier pour le markdown
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selected = content.substring(start, end);
|
||||
|
||||
let newContent = content;
|
||||
let newStart = start;
|
||||
let newEnd = end;
|
||||
|
||||
switch (e.key) {
|
||||
case "b": // Bold
|
||||
e.preventDefault();
|
||||
newContent =
|
||||
content.substring(0, start) +
|
||||
`**${selected}**` +
|
||||
content.substring(end);
|
||||
newStart = start + 2;
|
||||
newEnd = end + 2;
|
||||
break;
|
||||
case "i": // Italic
|
||||
e.preventDefault();
|
||||
newContent =
|
||||
content.substring(0, start) +
|
||||
`*${selected}*` +
|
||||
content.substring(end);
|
||||
newStart = start + 1;
|
||||
newEnd = end + 1;
|
||||
break;
|
||||
case "k": // Code
|
||||
e.preventDefault();
|
||||
newContent =
|
||||
content.substring(0, start) +
|
||||
`\`${selected}\`` +
|
||||
content.substring(end);
|
||||
newStart = start + 1;
|
||||
newEnd = end + 1;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
setContent(newContent);
|
||||
// Restaurer la sélection après le re-render
|
||||
requestAnimationFrame(() => {
|
||||
textarea.setSelectionRange(newStart, newEnd);
|
||||
});
|
||||
}
|
||||
},
|
||||
[content]
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-8">
|
||||
{/* Hidden input pour toujours envoyer le content */}
|
||||
<input type="hidden" name="content" value={content} />
|
||||
|
||||
{/* Métadonnées */}
|
||||
<FormSection
|
||||
title="Informations du tutoriel"
|
||||
icon={Folder1Outlined}
|
||||
>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<FormLabel icon={AlignTextLeftOutlined} required>
|
||||
Titre
|
||||
</FormLabel>
|
||||
<FormInput
|
||||
name="title"
|
||||
type="text"
|
||||
required
|
||||
defaultValue={initialData?.title}
|
||||
placeholder="Mon super tutoriel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FormLabel icon={Books2Outlined} required>
|
||||
Catégorie
|
||||
</FormLabel>
|
||||
<FormSelect
|
||||
name="categoryId"
|
||||
required
|
||||
defaultValue={initialData?.categoryId}
|
||||
>
|
||||
<option value="">
|
||||
Sélectionner une catégorie...
|
||||
</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</FormSelect>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FormLabel icon={Layers1Outlined}>Difficulté</FormLabel>
|
||||
<FormSelect
|
||||
name="difficulty"
|
||||
defaultValue={
|
||||
initialData?.difficulty || "Intermédiaire"
|
||||
}
|
||||
>
|
||||
<option value="Débutant">🌱 Débutant</option>
|
||||
<option value="Intermédiaire">
|
||||
🌿 Intermédiaire
|
||||
</option>
|
||||
<option value="Avancé">🌳 Avancé</option>
|
||||
</FormSelect>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FormLabel icon={StopwatchOutlined}>
|
||||
Temps de lecture
|
||||
</FormLabel>
|
||||
<div className="relative">
|
||||
<FormInput
|
||||
name="readTime"
|
||||
type="text"
|
||||
defaultValue={initialData?.readTime}
|
||||
placeholder="5 min"
|
||||
/>
|
||||
{content.length > 0 && (
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500 font-mono">
|
||||
Estimé: {estimatedReadTime()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<FormLabel icon={AlignTextLeftOutlined}>Extrait</FormLabel>
|
||||
<FormTextarea
|
||||
name="excerpt"
|
||||
rows={2}
|
||||
defaultValue={initialData?.excerpt}
|
||||
placeholder="Courte description qui s'affichera sur la page d'accueil..."
|
||||
/>
|
||||
<p className="mt-2 text-xs text-slate-500 font-mono">
|
||||
Laissez vide pour générer automatiquement depuis le
|
||||
contenu
|
||||
</p>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Éditeur Markdown */}
|
||||
<FormSection title="Contenu" icon={Code1Outlined} noPadding>
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center justify-between border-b border-slate-700/40 bg-slate-800/20">
|
||||
<div className="flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("edit")}
|
||||
className={`group flex items-center gap-2 px-6 py-3.5 text-sm font-mono transition-all duration-200 relative ${
|
||||
activeTab === "edit"
|
||||
? "text-cyan-400"
|
||||
: "text-slate-400 hover:text-slate-300"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`transition-transform duration-200 ${
|
||||
activeTab === "edit"
|
||||
? "scale-110"
|
||||
: "group-hover:scale-105"
|
||||
}`}
|
||||
>
|
||||
<Icon icon={Pencil1Outlined} size={16} />
|
||||
</span>
|
||||
<span>Éditer</span>
|
||||
{activeTab === "edit" && (
|
||||
<span className="absolute bottom-0 inset-x-0 h-0.5 bg-gradient-to-r from-cyan-500 to-cyan-400" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("preview")}
|
||||
className={`group flex items-center gap-2 px-6 py-3.5 text-sm font-mono transition-all duration-200 relative ${
|
||||
activeTab === "preview"
|
||||
? "text-cyan-400"
|
||||
: "text-slate-400 hover:text-slate-300"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`transition-transform duration-200 ${
|
||||
activeTab === "preview"
|
||||
? "scale-110"
|
||||
: "group-hover:scale-105"
|
||||
}`}
|
||||
>
|
||||
<Icon icon={EyeOutlined} size={16} />
|
||||
</span>
|
||||
<span>Aperçu</span>
|
||||
{activeTab === "preview" && (
|
||||
<span className="absolute bottom-0 inset-x-0 h-0.5 bg-gradient-to-r from-cyan-500 to-cyan-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Statistiques */}
|
||||
<div className="flex items-center gap-4 pr-6 text-xs font-mono text-slate-500">
|
||||
<span>{charCount.toLocaleString()} caractères</span>
|
||||
<span className="text-slate-700">|</span>
|
||||
<span>
|
||||
{content.trim().split(/\s+/).filter(Boolean).length}{" "}
|
||||
mots
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{activeTab === "edit" ? (
|
||||
<div className="space-y-3">
|
||||
{/* Barre d'outils markdown */}
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-slate-800/50 border border-slate-700/30">
|
||||
<Icon
|
||||
icon={KeyboardOutlined}
|
||||
size={14}
|
||||
className="text-slate-500"
|
||||
/>
|
||||
<span className="text-xs text-slate-500 font-mono mr-2">
|
||||
Raccourcis:
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<kbd className="px-2 py-1 text-xs font-mono bg-slate-700/50 text-slate-300 rounded border border-slate-600/50">
|
||||
Ctrl+B
|
||||
</kbd>
|
||||
<span className="text-slate-600 text-xs">
|
||||
Gras
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-3">
|
||||
<kbd className="px-2 py-1 text-xs font-mono bg-slate-700/50 text-slate-300 rounded border border-slate-600/50">
|
||||
Ctrl+I
|
||||
</kbd>
|
||||
<span className="text-slate-600 text-xs">
|
||||
Italique
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-3">
|
||||
<kbd className="px-2 py-1 text-xs font-mono bg-slate-700/50 text-slate-300 rounded border border-slate-600/50">
|
||||
Ctrl+K
|
||||
</kbd>
|
||||
<span className="text-slate-600 text-xs">
|
||||
Code
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={20}
|
||||
required
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full rounded-lg bg-slate-950/80 border border-slate-700/50 px-4 py-3 text-sm
|
||||
font-mono text-slate-100 placeholder:text-slate-600
|
||||
focus:outline-none focus:ring-2 focus:ring-cyan-500/30 focus:border-cyan-500/50
|
||||
hover:border-slate-600 transition-all duration-200
|
||||
resize-y min-h-[450px] leading-relaxed"
|
||||
placeholder={`# Mon tutoriel
|
||||
|
||||
## Introduction
|
||||
|
||||
Voici un exemple de contenu en **Markdown**.
|
||||
|
||||
## Étapes
|
||||
|
||||
1. Première étape
|
||||
2. Deuxième étape
|
||||
3. Troisième étape
|
||||
|
||||
## Code
|
||||
|
||||
\`\`\`typescript
|
||||
const hello = "world";
|
||||
console.log(hello);
|
||||
\`\`\`
|
||||
|
||||
## Conclusion
|
||||
|
||||
C'est tout pour ce tutoriel !`}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-slate-500 font-mono">
|
||||
<p>
|
||||
Supporte le Markdown : titres (#), gras
|
||||
(**), italique (*), code (`), listes,
|
||||
citations (>), liens, images...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-[450px] rounded-lg bg-slate-950/50 border border-slate-700/30 p-6 overflow-auto">
|
||||
{content.trim() ? (
|
||||
<MarkdownRenderer content={content} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full py-20 text-center">
|
||||
<div className="p-4 rounded-full bg-slate-800/50 mb-4">
|
||||
<Icon
|
||||
icon={EyeOutlined}
|
||||
size={32}
|
||||
className="text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-slate-500 font-mono text-sm">
|
||||
Commencez à écrire pour voir l'aperçu...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="group relative flex items-center gap-3 px-8 py-3.5 rounded-xl font-mono text-sm
|
||||
bg-gradient-to-r from-cyan-500/20 to-cyan-600/20
|
||||
text-cyan-400 border border-cyan-500/40
|
||||
hover:border-cyan-400/70 hover:text-cyan-300
|
||||
hover:from-cyan-500/30 hover:to-cyan-600/30
|
||||
hover:shadow-lg hover:shadow-cyan-500/20
|
||||
active:scale-[0.98]
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-300"
|
||||
>
|
||||
{/* Effet de brillance */}
|
||||
<span className="absolute inset-0 rounded-xl overflow-hidden">
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-transparent via-cyan-400/10 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-700" />
|
||||
</span>
|
||||
|
||||
<span className="relative z-10">
|
||||
<Icon icon={CheckCircle1Outlined} size={20} />
|
||||
</span>
|
||||
<span className="relative z-10">{submitLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
addCategory,
|
||||
deleteCategory,
|
||||
getAllCategories,
|
||||
updateCategory,
|
||||
} from "@/lib/tutorials";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ALLOWED_DISCORD_ID = "281113457833672706";
|
||||
|
||||
const PRESET_COLORS = [
|
||||
// Bleus & Cyans
|
||||
{ name: "Cyan", value: "#06b6d4" },
|
||||
{ name: "Sky", value: "#0ea5e9" },
|
||||
{ name: "Blue", value: "#3b82f6" },
|
||||
{ name: "Indigo", value: "#6366f1" },
|
||||
// Violets & Roses
|
||||
{ name: "Violet", value: "#8b5cf6" },
|
||||
{ name: "Purple", value: "#a855f7" },
|
||||
{ name: "Fuchsia", value: "#d946ef" },
|
||||
{ name: "Pink", value: "#ec4899" },
|
||||
{ name: "Rose", value: "#f43f5e" },
|
||||
// Chauds
|
||||
{ name: "Red", value: "#ef4444" },
|
||||
{ name: "Orange", value: "#f97316" },
|
||||
{ name: "Amber", value: "#f59e0b" },
|
||||
{ name: "Yellow", value: "#eab308" },
|
||||
// Verts
|
||||
{ name: "Lime", value: "#84cc16" },
|
||||
{ name: "Green", value: "#22c55e" },
|
||||
{ name: "Emerald", value: "#10b981" },
|
||||
{ name: "Teal", value: "#14b8a6" },
|
||||
// Neutres
|
||||
{ name: "Slate", value: "#64748b" },
|
||||
];
|
||||
|
||||
async function handleAddCategory(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
const session = await auth();
|
||||
if (
|
||||
!session?.user?.discordId ||
|
||||
session.user.discordId !== ALLOWED_DISCORD_ID
|
||||
) {
|
||||
throw new Error("Non autorisé");
|
||||
}
|
||||
|
||||
const name = formData.get("name")?.toString().trim();
|
||||
const color = formData.get("color")?.toString() || "#06b6d4";
|
||||
|
||||
if (!name) {
|
||||
throw new Error("Le nom est obligatoire");
|
||||
}
|
||||
|
||||
await addCategory(name, color);
|
||||
revalidatePath("/dashboard/categories");
|
||||
revalidatePath("/dashboard");
|
||||
}
|
||||
|
||||
async function handleDeleteCategory(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
const session = await auth();
|
||||
if (
|
||||
!session?.user?.discordId ||
|
||||
session.user.discordId !== ALLOWED_DISCORD_ID
|
||||
) {
|
||||
throw new Error("Non autorisé");
|
||||
}
|
||||
|
||||
const id = formData.get("id")?.toString();
|
||||
if (!id) throw new Error("ID manquant");
|
||||
|
||||
await deleteCategory(id);
|
||||
revalidatePath("/dashboard/categories");
|
||||
revalidatePath("/dashboard");
|
||||
}
|
||||
|
||||
async function handleUpdateCategory(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
const session = await auth();
|
||||
if (
|
||||
!session?.user?.discordId ||
|
||||
session.user.discordId !== ALLOWED_DISCORD_ID
|
||||
) {
|
||||
throw new Error("Non autorisé");
|
||||
}
|
||||
|
||||
const id = formData.get("id")?.toString();
|
||||
const name = formData.get("name")?.toString().trim();
|
||||
const color = formData.get("color")?.toString();
|
||||
|
||||
if (!id || !name) {
|
||||
throw new Error("ID et nom sont obligatoires");
|
||||
}
|
||||
|
||||
await updateCategory(id, { name, color });
|
||||
revalidatePath("/dashboard/categories");
|
||||
revalidatePath("/dashboard");
|
||||
}
|
||||
|
||||
export default async function CategoriesPage() {
|
||||
const categories = await getAllCategories();
|
||||
|
||||
// Compter les tutoriels par catégorie
|
||||
const counts = await prisma.tutorial.groupBy({
|
||||
by: ["categoryId"],
|
||||
_count: true,
|
||||
});
|
||||
const countMap = new Map(counts.map((c) => [c.categoryId, c._count]));
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
<span className="bg-linear-to-r from-cyan-400 via-violet-400 to-fuchsia-400 bg-clip-text text-transparent">
|
||||
Catégories
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm mt-1 font-mono">
|
||||
{categories.length} catégorie
|
||||
{categories.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Formulaire d'ajout */}
|
||||
<div className="bg-slate-900/80 border border-slate-700/50 rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-100 mb-4">
|
||||
Nouvelle catégorie
|
||||
</h2>
|
||||
|
||||
<form
|
||||
action={handleAddCategory}
|
||||
className="flex flex-wrap gap-4 items-end"
|
||||
>
|
||||
<div className="flex-1 min-w-[200px] space-y-2">
|
||||
<label className="block text-sm font-mono text-slate-400">
|
||||
Nom
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
className="w-full rounded-lg bg-slate-950 border border-slate-700 px-4 py-2.5 text-sm
|
||||
text-slate-100 placeholder:text-slate-500
|
||||
focus:outline-none focus:ring-2 focus:ring-cyan-500/40 focus:border-cyan-500/40"
|
||||
placeholder="Next.js, React, DevOps..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-mono text-slate-400">
|
||||
Couleur
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{PRESET_COLORS.map((color) => (
|
||||
<label
|
||||
key={color.value}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="color"
|
||||
value={color.value}
|
||||
defaultChecked={
|
||||
color.value === "#06b6d4"
|
||||
}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
className="w-8 h-8 rounded-full border-2 border-transparent
|
||||
peer-checked:border-white peer-checked:ring-2 peer-checked:ring-offset-2
|
||||
peer-checked:ring-offset-slate-900 peer-checked:ring-current
|
||||
transition-all"
|
||||
style={{ backgroundColor: color.value }}
|
||||
title={color.name}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2.5 rounded-lg font-mono text-sm
|
||||
bg-cyan-500/20 text-cyan-400 border border-cyan-500/40
|
||||
hover:border-cyan-400/80 hover:text-cyan-300 transition-colors"
|
||||
>
|
||||
Ajouter
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Liste des catégories */}
|
||||
{categories.length === 0 ? (
|
||||
<div className="bg-slate-900/80 border border-slate-700/50 rounded-lg p-12 text-center">
|
||||
<p className="text-slate-500 font-mono">
|
||||
Aucune catégorie pour l'instant
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-slate-900/80 border border-slate-700/50 rounded-lg overflow-hidden">
|
||||
<div className="divide-y divide-slate-800/50">
|
||||
{categories.map((cat) => {
|
||||
const tutorialCount = countMap.get(cat.id) || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cat.id}
|
||||
className="p-4 flex items-center justify-between hover:bg-slate-800/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full"
|
||||
style={{
|
||||
backgroundColor: cat.color,
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-slate-100 font-medium">
|
||||
{cat.name}
|
||||
</span>
|
||||
<span className="text-slate-500 text-xs font-mono ml-2">
|
||||
/{cat.slug}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 font-mono bg-slate-800 px-2 py-0.5 rounded">
|
||||
{tutorialCount} tuto
|
||||
{tutorialCount > 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Edit inline (simple) */}
|
||||
<form
|
||||
action={handleUpdateCategory}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="id"
|
||||
value={cat.id}
|
||||
/>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
defaultValue={cat.name}
|
||||
className="w-32 rounded bg-slate-950 border border-slate-700 px-2 py-1 text-xs
|
||||
text-slate-100 focus:outline-none focus:ring-1 focus:ring-cyan-500/40"
|
||||
/>
|
||||
<select
|
||||
name="color"
|
||||
defaultValue={cat.color}
|
||||
className="rounded bg-slate-950 border border-slate-700 px-2 py-1 text-xs
|
||||
text-slate-100 focus:outline-none focus:ring-1 focus:ring-cyan-500/40"
|
||||
>
|
||||
{PRESET_COLORS.map((color) => (
|
||||
<option
|
||||
key={color.value}
|
||||
value={color.value}
|
||||
>
|
||||
{color.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-2 py-1 text-xs font-mono rounded
|
||||
bg-slate-800/60 border border-slate-700/70 text-slate-400
|
||||
hover:border-cyan-500/50 hover:text-cyan-400 transition-colors"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Delete */}
|
||||
<form action={handleDeleteCategory}>
|
||||
<input
|
||||
type="hidden"
|
||||
name="id"
|
||||
value={cat.id}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={tutorialCount > 0}
|
||||
className="px-2 py-1 text-xs font-mono rounded
|
||||
bg-slate-800/60 border border-slate-700/70 text-slate-400
|
||||
hover:border-rose-500/50 hover:text-rose-400 transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-slate-700/70 disabled:hover:text-slate-400"
|
||||
title={
|
||||
tutorialCount > 0
|
||||
? "Impossible de supprimer: des tutoriels utilisent cette catégorie"
|
||||
: "Supprimer"
|
||||
}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import {
|
||||
generateSlug,
|
||||
getAllCategories,
|
||||
getTutorialById,
|
||||
updateTutorial,
|
||||
} from "@/lib/tutorials";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { TutorialForm } from "../../_components/tutorial-form";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ALLOWED_DISCORD_ID = "281113457833672706";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export default async function EditTutorialPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
const [tutorial, categories] = await Promise.all([
|
||||
getTutorialById(id),
|
||||
getAllCategories(),
|
||||
]);
|
||||
|
||||
if (!tutorial) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
async function handleUpdate(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
const session = await auth();
|
||||
if (
|
||||
!session?.user?.discordId ||
|
||||
session.user.discordId !== ALLOWED_DISCORD_ID
|
||||
) {
|
||||
throw new Error("Non autorisé");
|
||||
}
|
||||
|
||||
const title = formData.get("title")?.toString().trim();
|
||||
const categoryId = formData.get("categoryId")?.toString();
|
||||
const difficulty =
|
||||
formData.get("difficulty")?.toString() || "Intermédiaire";
|
||||
const readTime = formData.get("readTime")?.toString().trim();
|
||||
const excerpt = formData.get("excerpt")?.toString().trim();
|
||||
const content = formData.get("content")?.toString().trim();
|
||||
|
||||
if (!title || !categoryId || !content) {
|
||||
throw new Error("Titre, catégorie et contenu sont obligatoires.");
|
||||
}
|
||||
|
||||
await updateTutorial(id, {
|
||||
slug: generateSlug(title),
|
||||
title,
|
||||
categoryId,
|
||||
difficulty: difficulty as "Débutant" | "Intermédiaire" | "Avancé",
|
||||
readTime: readTime || "5 min",
|
||||
excerpt:
|
||||
excerpt ||
|
||||
(content.length > 180
|
||||
? content.slice(0, 180) + "..."
|
||||
: content),
|
||||
content,
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/");
|
||||
revalidatePath(`/tutos/${tutorial.slug}`);
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
<span className="bg-linear-to-r from-cyan-400 via-violet-400 to-fuchsia-400 bg-clip-text text-transparent">
|
||||
Éditer le tutoriel
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm mt-1 font-mono">
|
||||
{tutorial.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="px-4 py-2 rounded-lg font-mono text-sm
|
||||
bg-slate-800/60 border border-slate-700/70 text-slate-400
|
||||
hover:border-slate-600 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
← Retour
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<TutorialForm
|
||||
categories={categories}
|
||||
action={handleUpdate}
|
||||
submitLabel="Enregistrer les modifications"
|
||||
initialData={tutorial}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import { DashboardSidebar } from "./_components/DashboardSidebar";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dashboard | Jessy David",
|
||||
};
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
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>
|
||||
|
||||
<div className="relative z-10 flex">
|
||||
<DashboardSidebar />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 p-8">
|
||||
<div className="max-w-5xl mx-auto">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { addTutorial, generateSlug, getAllCategories } from "@/lib/tutorials";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { TutorialForm } from "../_components/tutorial-form";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ALLOWED_DISCORD_ID = "281113457833672706";
|
||||
|
||||
async function createTutorial(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
const session = await auth();
|
||||
if (
|
||||
!session?.user?.discordId ||
|
||||
session.user.discordId !== ALLOWED_DISCORD_ID
|
||||
) {
|
||||
throw new Error("Non autorisé");
|
||||
}
|
||||
|
||||
const title = formData.get("title")?.toString().trim();
|
||||
const categoryId = formData.get("categoryId")?.toString();
|
||||
const difficulty =
|
||||
formData.get("difficulty")?.toString() || "Intermédiaire";
|
||||
const readTime = formData.get("readTime")?.toString().trim();
|
||||
const excerpt = formData.get("excerpt")?.toString().trim();
|
||||
const content = formData.get("content")?.toString().trim();
|
||||
|
||||
if (!title || !categoryId || !content) {
|
||||
throw new Error("Titre, catégorie et contenu sont obligatoires.");
|
||||
}
|
||||
|
||||
await addTutorial({
|
||||
slug: generateSlug(title),
|
||||
title,
|
||||
categoryId,
|
||||
difficulty: difficulty as "Débutant" | "Intermédiaire" | "Avancé",
|
||||
readTime: readTime || "5 min",
|
||||
excerpt:
|
||||
excerpt ||
|
||||
(content.length > 180 ? content.slice(0, 180) + "..." : content),
|
||||
content,
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/");
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
export default async function NewTutorialPage() {
|
||||
const categories = await getAllCategories();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
<span className="bg-linear-to-r from-cyan-400 via-violet-400 to-fuchsia-400 bg-clip-text text-transparent">
|
||||
Nouveau tutoriel
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm mt-1 font-mono">
|
||||
Créez un nouveau tutoriel avec preview Markdown
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="px-4 py-2 rounded-lg font-mono text-sm
|
||||
bg-slate-800/60 border border-slate-700/70 text-slate-400
|
||||
hover:border-slate-600 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
← Retour
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{categories.length === 0 ? (
|
||||
<div className="bg-slate-900/80 border border-amber-500/30 rounded-lg p-8 text-center">
|
||||
<p className="text-amber-400 font-mono mb-4">
|
||||
⚠️ Vous devez d'abord créer au moins une catégorie
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard/categories"
|
||||
className="text-cyan-400 hover:text-cyan-300 font-mono text-sm"
|
||||
>
|
||||
Gérer les catégories →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<TutorialForm
|
||||
categories={categories}
|
||||
action={createTutorial}
|
||||
submitLabel="Créer le tutoriel"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { LogoutButton } from "@/components/ui/LogoutButton";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { deleteTutorial, getAllTutorials } from "@/lib/tutorials";
|
||||
import {
|
||||
CalendarDaysOutlined,
|
||||
PenToSquareOutlined,
|
||||
PlusOutlined,
|
||||
Trash3Outlined,
|
||||
} from "@lineiconshq/free-icons";
|
||||
import { Lineicons } from "@lineiconshq/react-lineicons";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function handleDelete(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
throw new Error("Non autorisé");
|
||||
}
|
||||
|
||||
const id = formData.get("id")?.toString();
|
||||
if (!id) throw new Error("ID manquant");
|
||||
|
||||
await deleteTutorial(id);
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const tutorials = await getAllTutorials();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
<span className="bg-linear-to-r from-cyan-400 via-violet-400 to-fuchsia-400 bg-clip-text text-transparent">
|
||||
Tutoriels
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm mt-1 font-mono">
|
||||
{tutorials.length} tutoriel
|
||||
{tutorials.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/dashboard/nouveau"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg font-mono text-sm
|
||||
bg-cyan-500/20 text-cyan-400 border border-cyan-500/40
|
||||
hover:border-cyan-400/80 hover:text-cyan-300 transition-colors"
|
||||
>
|
||||
<Lineicons icon={PlusOutlined} size={16} />
|
||||
Nouveau tutoriel
|
||||
</Link>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liste */}
|
||||
{tutorials.length === 0 ? (
|
||||
<div className="bg-slate-900/80 border border-slate-700/50 rounded-lg p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-800/50 flex items-center justify-center">
|
||||
<Lineicons
|
||||
icon={PlusOutlined}
|
||||
size={32}
|
||||
className="text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-slate-500 font-mono mb-4">
|
||||
Aucun tutoriel pour l'instant
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-slate-900/80 border border-slate-700/50 rounded-lg overflow-hidden">
|
||||
{/* Table header */}
|
||||
<div className="grid grid-cols-12 gap-4 px-6 py-3 bg-slate-800/50 border-b border-slate-700/50 text-xs font-mono text-slate-500 uppercase tracking-wider">
|
||||
<div className="col-span-5">Titre</div>
|
||||
<div className="col-span-2">Catégorie</div>
|
||||
<div className="col-span-2">Difficulté</div>
|
||||
<div className="col-span-1">Date</div>
|
||||
<div className="col-span-2 text-right">Actions</div>
|
||||
</div>
|
||||
|
||||
{/* Table body */}
|
||||
<div className="divide-y divide-slate-800/50">
|
||||
{tutorials.map((tuto) => (
|
||||
<div
|
||||
key={tuto.id}
|
||||
className="grid grid-cols-12 gap-4 px-6 py-4 items-center hover:bg-slate-800/30 transition-colors group"
|
||||
>
|
||||
<div className="col-span-5">
|
||||
<Link
|
||||
href={`/tutos/${tuto.slug}`}
|
||||
className="text-slate-100 hover:text-cyan-400 transition-colors font-medium"
|
||||
>
|
||||
{tuto.title}
|
||||
</Link>
|
||||
<p className="text-xs text-slate-500 font-mono mt-0.5">
|
||||
/{tuto.slug}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<span
|
||||
className="px-2 py-1 text-xs font-mono rounded border"
|
||||
style={{
|
||||
backgroundColor: `${tuto.category.color}20`,
|
||||
borderColor: `${tuto.category.color}40`,
|
||||
color: tuto.category.color,
|
||||
}}
|
||||
>
|
||||
{tuto.category.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<DifficultyBadge level={tuto.difficulty} />
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 flex items-center gap-1.5 text-xs text-slate-500 font-mono">
|
||||
<Lineicons
|
||||
icon={CalendarDaysOutlined}
|
||||
size={14}
|
||||
className="text-slate-600"
|
||||
/>
|
||||
{new Date(tuto.date).toLocaleDateString(
|
||||
"fr-FR",
|
||||
{
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 flex items-center justify-end gap-2">
|
||||
<Link
|
||||
href={`/dashboard/editer/${tuto.id}`}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-mono rounded
|
||||
bg-slate-800/60 border border-slate-700/70 text-slate-400
|
||||
hover:border-cyan-500/50 hover:text-cyan-400 transition-colors"
|
||||
>
|
||||
<Lineicons
|
||||
icon={PenToSquareOutlined}
|
||||
size={14}
|
||||
/>
|
||||
Éditer
|
||||
</Link>
|
||||
<form action={handleDelete}>
|
||||
<input
|
||||
type="hidden"
|
||||
name="id"
|
||||
value={tuto.id}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center gap-1.5 cursor-pointer px-3 py-1.5 text-xs font-mono rounded
|
||||
bg-slate-800/60 border border-slate-700/70 text-slate-400
|
||||
hover:border-rose-500/50 hover:text-rose-400 transition-colors"
|
||||
>
|
||||
<Lineicons
|
||||
icon={Trash3Outlined}
|
||||
size={14}
|
||||
/>
|
||||
Supprimer
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DifficultyBadge({ level }: { level: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
Débutant: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
|
||||
Intermédiaire: "bg-amber-500/20 text-amber-400 border-amber-500/30",
|
||||
Avancé: "bg-rose-500/20 text-rose-400 border-rose-500/30",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-mono border rounded ${
|
||||
colors[level] || colors["Débutant"]
|
||||
}`}
|
||||
>
|
||||
{level}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
+17
-24
@@ -1,34 +1,27 @@
|
||||
import { Footer, Header } from "@/components/layout";
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
import { Providers } from "./providers";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Tutos | Jessy David",
|
||||
description: "Mes tutoriels personnels",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
return (
|
||||
<html lang="fr">
|
||||
<body className="antialiased">
|
||||
<Providers>
|
||||
<Header />
|
||||
{children}
|
||||
<Footer />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { signIn } from "@/lib/auth";
|
||||
|
||||
export async function loginWithDiscord() {
|
||||
await signIn("discord", { redirectTo: "/dashboard" });
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { LoginContent } from "@/components/auth/LoginContent";
|
||||
import { auth } from "@/lib/auth";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Connexion | Jessy David",
|
||||
description: "Connexion via Discord pour accéder au dashboard.",
|
||||
};
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
};
|
||||
|
||||
export default async function LoginPage({ searchParams }: PageProps) {
|
||||
const session = await auth();
|
||||
|
||||
// Déjà connecté -> Redirige vers dashboard
|
||||
if (session) {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
const { error } = await searchParams;
|
||||
return <LoginContent error={error} />;
|
||||
}
|
||||
+47
-63
@@ -1,65 +1,49 @@
|
||||
import Image from "next/image";
|
||||
import { HomeContent } from "@/components/home";
|
||||
import type { Category } from "@/components/tutorials";
|
||||
import { getAllTutorials } from "@/lib/tutorials";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
export const metadata: Metadata = {
|
||||
title: "Tutos | Jessy David",
|
||||
description: "Mes tutoriels",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function extractCategories(
|
||||
tutorials: Awaited<ReturnType<typeof getAllTutorials>>
|
||||
): Category[] {
|
||||
const categoriesMap = new Map<string, Category>();
|
||||
tutorials.forEach((t) => {
|
||||
if (!categoriesMap.has(t.category.id)) {
|
||||
categoriesMap.set(t.category.id, {
|
||||
id: t.category.id,
|
||||
name: t.category.name,
|
||||
color: t.category.color,
|
||||
});
|
||||
}
|
||||
});
|
||||
return Array.from(categoriesMap.values());
|
||||
}
|
||||
|
||||
export default async function HomePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ category?: string }>;
|
||||
}) {
|
||||
const { category } = await searchParams;
|
||||
const allTutorials = await getAllTutorials();
|
||||
const categories = extractCategories(allTutorials);
|
||||
|
||||
const tutorials = category
|
||||
? allTutorials.filter((t) => t.category.id === category)
|
||||
: allTutorials.slice(0, 4);
|
||||
|
||||
return (
|
||||
<HomeContent
|
||||
tutorials={tutorials}
|
||||
categories={categories}
|
||||
allTutorialsCount={allTutorials.length}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { BackgroundEffects } from "@/components/layout";
|
||||
import {
|
||||
CategoryFilters,
|
||||
TutorialsGrid,
|
||||
type Category,
|
||||
} from "@/components/tutorials";
|
||||
import { HeroSection } from "@/components/ui";
|
||||
import { getAllTutorials } from "@/lib/tutorials";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tutos | Jessy David",
|
||||
description: "Mes tutoriels",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function extractCategories(
|
||||
tutorials: Awaited<ReturnType<typeof getAllTutorials>>
|
||||
): Category[] {
|
||||
const categoriesMap = new Map<string, Category>();
|
||||
tutorials.forEach((t) => {
|
||||
if (!categoriesMap.has(t.category.id)) {
|
||||
categoriesMap.set(t.category.id, {
|
||||
id: t.category.id,
|
||||
name: t.category.name,
|
||||
color: t.category.color,
|
||||
});
|
||||
}
|
||||
});
|
||||
return Array.from(categoriesMap.values());
|
||||
}
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{ category?: string }>;
|
||||
};
|
||||
|
||||
export default async function TutosPage({ searchParams }: PageProps) {
|
||||
const { category } = await searchParams;
|
||||
const allTutorials = await getAllTutorials();
|
||||
const categories = extractCategories(allTutorials);
|
||||
|
||||
const tutorials = category
|
||||
? allTutorials.filter((t) => t.category.id === category)
|
||||
: allTutorials;
|
||||
|
||||
const activeCategoryName = category
|
||||
? categories.find((c) => c.id === category)?.name
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-100 selection:bg-cyan-500/30">
|
||||
<BackgroundEffects />
|
||||
<div className="relative z-10">
|
||||
<HeroSection
|
||||
tutorialsCount={tutorials.length}
|
||||
categoriesCount={categories.length}
|
||||
title={activeCategoryName}
|
||||
filtered={!!category}
|
||||
/>
|
||||
<CategoryFilters categories={categories} />
|
||||
<TutorialsGrid tutorials={tutorials} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user