feat: ajout structure projet + config Node 20

This commit is contained in:
UltraLionFr
2025-12-07 00:11:21 +01:00
parent c728019f04
commit 25d49d04f0
60 changed files with 4260 additions and 157 deletions
@@ -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>
);
}
+524
View File
@@ -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 (&gt;), 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>
);
}
+314
View File
@@ -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&apos;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>
);
}
+108
View File
@@ -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>
);
}
+37
View File
@@ -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>
);
}
+101
View File
@@ -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&apos;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>
);
}
+207
View File
@@ -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&apos;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>
);
}