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
+2
View File
@@ -39,3 +39,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
/lib/generated/prisma
+5
View File
@@ -0,0 +1,5 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"WillLuke.nextjs.addTypesOnSave": true,
"WillLuke.nextjs.hasPrompted": true
}
+3
View File
@@ -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>
);
}
+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>
);
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+10 -17
View File
@@ -1,20 +1,11 @@
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({
@@ -23,11 +14,13 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<html lang="fr">
<body className="antialiased">
<Providers>
<Header />
{children}
<Footer />
</Providers>
</body>
</html>
);
+7
View File
@@ -0,0 +1,7 @@
"use server";
import { signIn } from "@/lib/auth";
export async function loginWithDiscord() {
await signIn("discord", { redirectTo: "/dashboard" });
}
+25
View File
@@ -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} />;
}
+44 -60
View File
@@ -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 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);
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
<HomeContent
tutorials={tutorials}
categories={categories}
allTutorialsCount={allTutorials.length}
/>
<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>
);
}
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { SessionProvider } from "next-auth/react";
export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}
+233
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
};
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) }}
/>
);
}
+110
View File
@@ -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>
);
}
+66
View File
@@ -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>
);
}
+213
View File
@@ -0,0 +1,213 @@
"use client";
import { loginWithDiscord } from "@/app/login/actions";
import gsap from "gsap";
import { useEffect, useRef } from "react";
import { SiDiscord } from "react-icons/si";
interface LoginContentProps {
error?: string;
}
export function LoginContent({ error }: LoginContentProps) {
const containerRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLElement>(null);
const dotsRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const errorRef = useRef<HTMLDivElement>(null);
const bgOrb1Ref = useRef<HTMLDivElement>(null);
const bgOrb2Ref = useRef<HTMLDivElement>(null);
const getErrorMessage = (code?: string) => {
if (!code) return null;
if (code === "unauthorized") {
return "Cet utilisateur Discord n'est pas autorisé.";
}
if (code === "oauth_failed") {
return "La connexion avec Discord a échoué. Réessaie.";
}
return "Une erreur est survenue.";
};
const errorMessage = getErrorMessage(error);
useEffect(() => {
const ctx = gsap.context(() => {
const tl = gsap.timeline({ defaults: { ease: "power3.out" } });
gsap.to(bgOrb1Ref.current, {
x: 50,
y: 30,
duration: 8,
repeat: -1,
yoyo: true,
ease: "sine.inOut",
});
gsap.to(bgOrb2Ref.current, {
x: -40,
y: -20,
duration: 6,
repeat: -1,
yoyo: true,
ease: "sine.inOut",
});
tl.fromTo(
cardRef.current,
{ opacity: 0, y: 40, scale: 0.95 },
{ opacity: 1, y: 0, scale: 1, duration: 0.8 }
);
tl.fromTo(
dotsRef.current?.children || [],
{ scale: 0, opacity: 0 },
{
scale: 1,
opacity: 1,
duration: 0.4,
stagger: 0.1,
ease: "back.out(1.7)",
},
"-=0.4"
);
tl.fromTo(
titleRef.current,
{ opacity: 0, clipPath: "inset(0 100% 0 0)" },
{ opacity: 1, clipPath: "inset(0 0% 0 0)", duration: 0.6 },
"-=0.2"
);
if (errorRef.current) {
tl.fromTo(
errorRef.current,
{ opacity: 0, x: -20 },
{ opacity: 1, x: 0, duration: 0.4 },
"-=0.2"
);
}
tl.fromTo(
buttonRef.current,
{ opacity: 0, y: 20 },
{ opacity: 1, y: 0, duration: 0.5 },
"-=0.2"
);
gsap.to(buttonRef.current, {
boxShadow: "0 0 20px rgba(99, 102, 241, 0.3)",
duration: 1.5,
repeat: -1,
yoyo: true,
ease: "sine.inOut",
});
}, containerRef);
return () => ctx.revert();
}, []);
const handleButtonHover = () => {
gsap.to(buttonRef.current, {
scale: 1.02,
duration: 0.3,
ease: "power2.out",
});
};
const handleButtonLeave = () => {
gsap.to(buttonRef.current, {
scale: 1,
duration: 0.3,
ease: "power2.out",
});
};
return (
<div
ref={containerRef}
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
ref={bgOrb1Ref}
className="absolute top-0 left-1/4 w-96 h-96 bg-cyan-500/5 rounded-full blur-3xl"
/>
<div
ref={bgOrb2Ref}
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 flex items-center justify-center min-h-screen px-6">
<main
ref={cardRef}
className="w-full max-w-md bg-slate-900/80 border border-slate-700/50 rounded-xl p-8 shadow-lg backdrop-blur opacity-0"
>
{/* Header type terminal */}
<div className="flex items-center gap-2 mb-6 pb-3 border-b border-slate-700/50">
<div ref={dotsRef} className="flex gap-1.5">
<span className="w-3 h-3 rounded-full bg-rose-500/80" />
<span className="w-3 h-3 rounded-full bg-amber-500/80" />
<span className="w-3 h-3 rounded-full bg-emerald-500/80" />
</div>
<span className="text-slate-500 text-xs font-mono ml-2">
~/auth/login/
</span>
</div>
<section className="space-y-5">
<div className="space-y-2">
<h1
ref={titleRef}
className="text-3xl font-bold tracking-tight opacity-0"
>
<span className="bg-linear-to-r from-cyan-400 via-violet-400 to-fuchsia-400 bg-clip-text text-transparent">
Connexion
</span>
</h1>
</div>
{/* Message d'erreur éventuel */}
{errorMessage && (
<div
ref={errorRef}
className="rounded border border-rose-500/40 bg-rose-500/10 px-3 py-2 text-sm text-rose-200 opacity-0"
>
{errorMessage}
</div>
)}
{/* Bouton Discord */}
<form action={loginWithDiscord} className="pt-2">
<button
ref={buttonRef}
type="submit"
onMouseEnter={handleButtonHover}
onMouseLeave={handleButtonLeave}
className="group flex items-center justify-center gap-3 w-full px-4 py-3 rounded-lg
bg-indigo-500/20 hover:bg-indigo-500/30
border border-indigo-400/40 hover:border-indigo-300
text-indigo-100 transition-colors opacity-0 cursor-pointer"
>
<SiDiscord className="w-5 h-5 text-indigo-300 group-hover:text-indigo-100 transition-colors" />
<span className="font-mono text-sm">
Se connecter avec Discord
</span>
</button>
</form>
</section>
</main>
</div>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
import { BackgroundEffects } from "@/components/layout";
import {
CategoryFilters,
TutorialsGrid,
type Category,
} from "@/components/tutorials";
import { HeroSection } from "@/components/ui";
import type { Tutorial } from "@/lib/tutorials";
import gsap from "gsap";
import { useEffect, useRef } from "react";
interface HomeContentProps {
tutorials: Tutorial[];
categories: Category[];
allTutorialsCount: number;
}
export function HomeContent({
tutorials,
categories,
allTutorialsCount,
}: HomeContentProps) {
const containerRef = useRef<HTMLDivElement>(null);
const heroRef = useRef<HTMLDivElement>(null);
const filtersRef = useRef<HTMLDivElement>(null);
const gridRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const ctx = gsap.context(() => {
const tl = gsap.timeline({ defaults: { ease: "power3.out" } });
// Hero section fade in + slide up
tl.fromTo(
heroRef.current,
{ y: 60, opacity: 0 },
{ y: 0, opacity: 1, duration: 0.8 }
);
// Filters stagger in
tl.fromTo(
filtersRef.current,
{ y: 30, opacity: 0 },
{ y: 0, opacity: 1, duration: 0.6 },
"-=0.4"
);
// Grid cards stagger in
const cards = gridRef.current?.querySelectorAll("article");
if (cards && cards.length > 0) {
tl.fromTo(
cards,
{ y: 50, opacity: 0, scale: 0.95 },
{
y: 0,
opacity: 1,
scale: 1,
duration: 0.6,
stagger: 0.15,
ease: "back.out(1.2)",
},
"-=0.3"
);
}
}, containerRef);
return () => ctx.revert();
}, [tutorials]);
return (
<div
ref={containerRef}
className="min-h-screen bg-slate-950 text-slate-100 selection:bg-cyan-500/30"
>
<BackgroundEffects />
<div className="relative z-10">
<div ref={heroRef} className="opacity-0">
<HeroSection
tutorialsCount={allTutorialsCount}
categoriesCount={categories.length}
/>
</div>
<div ref={filtersRef} className="opacity-0">
<CategoryFilters categories={categories} />
</div>
<div ref={gridRef}>
<TutorialsGrid tutorials={tutorials} />
</div>
</div>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export { HomeContent } from "./HomeContent";
+3
View File
@@ -0,0 +1,3 @@
export * from "./layout";
export * from "./tutorials";
export * from "./ui";
+14
View File
@@ -0,0 +1,14 @@
export function BackgroundEffects() {
return (
<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>
);
}
+197
View File
@@ -0,0 +1,197 @@
"use client";
import { siteConfig } from "@/config/site";
import {
GithubOutlined,
LinkedinOutlined,
XOutlined,
} from "@lineiconshq/free-icons";
import { Lineicons } from "@lineiconshq/react-lineicons";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Link from "next/link";
import { useEffect, useRef } from "react";
gsap.registerPlugin(ScrollTrigger);
const socialIcons = {
github: GithubOutlined,
linkedin: LinkedinOutlined,
x: XOutlined,
} as const;
const navLinks = [
{ href: "/", label: "Accueil" },
{ href: "/tutos", label: "Tutoriels" },
{ href: "/dashboard", label: "Dashboard" },
];
export function Footer() {
const footerRef = useRef<HTMLElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const logoRef = useRef<HTMLDivElement>(null);
const linksRef = useRef<HTMLDivElement>(null);
const socialRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const ctx = gsap.context(() => {
const tl = gsap.timeline({
scrollTrigger: {
trigger: footerRef.current,
start: "top 90%",
toggleActions: "play none none reverse",
},
});
tl.fromTo(
logoRef.current,
{ opacity: 0, y: 30 },
{ opacity: 1, y: 0, duration: 0.6, ease: "power2.out" }
);
tl.fromTo(
linksRef.current?.querySelectorAll("a") || [],
{ opacity: 0, y: 20 },
{
opacity: 1,
y: 0,
duration: 0.4,
stagger: 0.1,
ease: "power2.out",
},
"-=0.3"
);
tl.fromTo(
socialRef.current?.querySelectorAll("a") || [],
{ opacity: 0, scale: 0.8 },
{
opacity: 1,
scale: 1,
duration: 0.4,
stagger: 0.1,
ease: "back.out(1.7)",
},
"-=0.3"
);
tl.fromTo(
bottomRef.current,
{ opacity: 0 },
{ opacity: 1, duration: 0.5 },
"-=0.2"
);
}, footerRef);
return () => ctx.revert();
}, []);
const handleSocialHover = (e: React.MouseEvent, entering: boolean) => {
gsap.to(e.currentTarget, {
scale: entering ? 1.15 : 1,
y: entering ? -3 : 0,
duration: 0.25,
ease: "power2.out",
});
};
return (
<footer
ref={footerRef}
className="relative border-t border-slate-800/50 bg-slate-950"
>
{/* Gradient top border */}
<div className="absolute top-0 left-0 right-0 h-px bg-linear-to-r from-transparent via-cyan-500/50 to-transparent" />
<div ref={contentRef} className="max-w-6xl mx-auto px-6 py-12">
<div className="grid grid-cols-1 md:grid-cols-3 gap-10 md:gap-8">
{/* Logo & description */}
<div ref={logoRef} className="space-y-4">
<Link href="/" className="inline-block group">
<span className="text-xl font-bold text-slate-100 group-hover:text-cyan-400 transition-colors">
{siteConfig.name}
</span>
</Link>
<p className="text-sm text-slate-500 leading-relaxed">
Une collection de guides pratiques. Apprenez à votre
rythme avec des exemples concrets.
</p>
<p className="font-mono text-xs text-slate-600">
tutos.jessy-david.dev
</p>
</div>
{/* Navigation */}
<div ref={linksRef} className="space-y-4">
<h3 className="text-sm font-semibold uppercase tracking-wider text-slate-400">
Navigation
</h3>
<nav className="flex flex-col gap-2">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="group relative text-sm text-slate-500 hover:text-cyan-400 transition-colors w-fit"
>
{link.label}
<span className="absolute -bottom-0.5 left-0 h-px w-0 bg-linear-to-r from-cyan-400 to-cyan-500 transition-all duration-600 ease-out group-hover:w-full" />
</Link>
))}
</nav>
</div>
{/* Social */}
<div className="space-y-4">
<h3 className="text-sm font-semibold uppercase tracking-wider text-slate-400">
Suivez-moi
</h3>
<div
ref={socialRef}
className="flex items-center gap-3"
>
{Object.entries(siteConfig.socials).map(
([key, social]) => (
<Link
key={key}
href={social.url}
target="_blank"
rel="noopener noreferrer"
aria-label={social.label}
onMouseEnter={(e) =>
handleSocialHover(e, true)
}
onMouseLeave={(e) =>
handleSocialHover(e, false)
}
className="w-10 h-10 rounded-lg bg-slate-800/50 border border-slate-700/50 flex items-center justify-center text-slate-400 hover:text-cyan-400 hover:border-cyan-500/30 transition-colors"
>
<Lineicons
icon={
socialIcons[
key as keyof typeof socialIcons
]
}
size={20}
/>
</Link>
)
)}
</div>
</div>
</div>
{/* Bottom bar */}
<div
ref={bottomRef}
className="mt-12 pt-6 border-t border-slate-800/50 flex flex-col sm:flex-row items-center justify-between gap-4"
>
<p className="text-xs text-slate-600 font-mono">
&copy; {new Date().getFullYear()} {siteConfig.name}.
Tous droits réservés.
</p>
</div>
</div>
</footer>
);
}
+327
View File
@@ -0,0 +1,327 @@
"use client";
import { useGSAP } from "@gsap/react";
import {
Book1Outlined,
DashboardSquare1Outlined,
Home2Outlined,
} from "@lineiconshq/free-icons";
import { Lineicons } from "@lineiconshq/react-lineicons";
import gsap from "gsap";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useRef, useState } from "react";
// Constantes extraites du composant
const NAV_LINKS = [
{ href: "/", label: "Accueil", icon: Home2Outlined },
{ href: "/tutos", label: "Tous les tutos", icon: Book1Outlined },
{ href: "/dashboard", label: "Dashboard", icon: DashboardSquare1Outlined },
] as const;
// Types
type NavLinkType = (typeof NAV_LINKS)[number];
interface NavLinkProps {
link: NavLinkType;
isActive: boolean;
onClick?: () => void;
mobile?: boolean;
}
// Composant NavLink réutilisable
function NavLink({ link, isActive, onClick, mobile = false }: NavLinkProps) {
const baseClass = mobile
? "px-4 py-3 text-sm font-medium rounded-lg transition-colors flex items-center gap-3"
: "relative px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2";
const activeClass = mobile
? "text-cyan-400 bg-cyan-500/10 border-l-2 border-cyan-400"
: "text-cyan-400 active";
const inactiveClass = mobile
? "text-slate-400 hover:text-slate-100 hover:bg-slate-800/50"
: "text-slate-400 hover:text-slate-100";
return (
<Link
href={link.href}
onClick={onClick}
className={`${baseClass} ${isActive ? activeClass : inactiveClass}`}
aria-current={isActive ? "page" : undefined}
>
<Lineicons icon={link.icon} size={mobile ? 20 : 18} />
{link.label}
</Link>
);
}
// Composant SkipLink pour l'accessibilité
function SkipLink() {
return (
<Link
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-100 focus:px-4 focus:py-2 focus:bg-cyan-500 focus:text-white focus:rounded-lg focus:outline-none"
>
Aller au contenu principal
</Link>
);
}
export function Header() {
const pathname = usePathname();
const headerRef = useRef<HTMLElement>(null);
const logoRef = useRef<HTMLDivElement>(null);
const navRef = useRef<HTMLElement>(null);
const indicatorRef = useRef<HTMLSpanElement>(null);
const mobileMenuRef = useRef<HTMLDivElement>(null);
const [mobileOpen, setMobileOpen] = useState(false);
const isActive = (href: string) => {
if (href === "/") return pathname === "/";
return pathname.startsWith(href);
};
// Animation d'entrée avec useGSAP
useGSAP(
() => {
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (prefersReducedMotion) return;
gsap.fromTo(
logoRef.current,
{ opacity: 0, x: -20 },
{ opacity: 1, x: 0, duration: 0.5, ease: "power2.out" }
);
const links = navRef.current?.querySelectorAll("a");
if (links) {
gsap.fromTo(
links,
{ opacity: 0, y: -10 },
{
opacity: 1,
y: 0,
duration: 0.4,
stagger: 0.08,
ease: "power2.out",
delay: 0.2,
}
);
}
},
{ scope: headerRef }
);
// Indicateur animé sur le lien actif
useGSAP(
() => {
if (!navRef.current || !indicatorRef.current) return;
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
const activeLink = navRef.current.querySelector(
"a.active"
) as HTMLElement;
if (activeLink) {
gsap.to(indicatorRef.current, {
x: activeLink.offsetLeft,
width: activeLink.offsetWidth,
duration: prefersReducedMotion ? 0 : 0.3,
ease: "power2.out",
});
}
},
{ scope: headerRef, dependencies: [pathname] }
);
// Animation menu mobile
useGSAP(
() => {
if (!mobileMenuRef.current) return;
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (mobileOpen) {
gsap.fromTo(
mobileMenuRef.current,
{ height: 0, opacity: 0 },
{
height: "auto",
opacity: 1,
duration: prefersReducedMotion ? 0 : 0.3,
ease: "power2.out",
}
);
if (!prefersReducedMotion) {
const links = mobileMenuRef.current.querySelectorAll("a");
gsap.fromTo(
links,
{ opacity: 0, x: -15 },
{
opacity: 1,
x: 0,
duration: 0.25,
stagger: 0.05,
delay: 0.1,
}
);
}
} else {
gsap.to(mobileMenuRef.current, {
height: 0,
opacity: 0,
duration: prefersReducedMotion ? 0 : 0.2,
ease: "power2.in",
});
}
},
{ scope: headerRef, dependencies: [mobileOpen] }
);
const handleLogoHover = (enter: boolean) => {
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (prefersReducedMotion) return;
const img = logoRef.current?.querySelector("img");
if (!img) return;
gsap.to(img, {
scale: enter ? 1.1 : 1,
duration: 0.25,
ease: "power2.out",
});
};
return (
<>
<SkipLink />
<header
ref={headerRef}
className="sticky top-0 z-50 border-b border-slate-800/50 bg-slate-950/90 backdrop-blur-md"
role="banner"
>
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
{/* Logo */}
<div ref={logoRef}>
<Link
href="/"
onMouseEnter={() => handleLogoHover(true)}
onMouseLeave={() => handleLogoHover(false)}
className="flex items-center gap-3"
aria-label="Jessy David - Accueil"
>
<div className="relative w-10 h-10 rounded-lg overflow-hidden ring-1 ring-slate-700/50 hover:ring-cyan-500/50 transition-shadow">
<Image
src="/logo.webp"
alt=""
width={40}
height={40}
className="object-cover"
priority
/>
</div>
<div className="hidden sm:flex flex-col">
<span className="font-semibold text-slate-100 text-sm">
Jessy David
</span>
<span className="font-mono text-slate-500 text-xs">
tutos.jessy-david.dev
</span>
</div>
</Link>
</div>
{/* Desktop Nav */}
<nav
ref={navRef}
className="hidden md:flex items-center relative"
aria-label="Navigation principale"
>
{/* Indicateur animé */}
<span
ref={indicatorRef}
className="absolute bottom-0 h-0.5 bg-cyan-400 rounded-full pointer-events-none"
style={{ width: 0 }}
aria-hidden="true"
/>
{NAV_LINKS.map((link) => (
<NavLink
key={link.href}
link={link}
isActive={isActive(link.href)}
/>
))}
</nav>
{/* Mobile Button */}
<button
onClick={() => setMobileOpen((prev) => !prev)}
className="md:hidden w-10 h-10 flex flex-col items-center justify-center gap-1.5 rounded-lg bg-slate-800/50 border border-slate-700/50 text-slate-400 hover:text-cyan-400 hover:border-cyan-500/30 transition-colors"
aria-label={
mobileOpen ? "Fermer le menu" : "Ouvrir le menu"
}
aria-expanded={mobileOpen}
aria-controls="mobile-menu"
type="button"
>
<span
className={`block w-5 h-0.5 bg-current rounded-full transition-transform duration-200 ${
mobileOpen ? "rotate-45 translate-y-2" : ""
}`}
aria-hidden="true"
/>
<span
className={`block w-5 h-0.5 bg-current rounded-full transition-opacity duration-200 ${
mobileOpen ? "opacity-0" : ""
}`}
aria-hidden="true"
/>
<span
className={`block w-5 h-0.5 bg-current rounded-full transition-transform duration-200 ${
mobileOpen ? "-rotate-45 -translate-y-2" : ""
}`}
aria-hidden="true"
/>
</button>
</div>
{/* Mobile Menu */}
<div
id="mobile-menu"
ref={mobileMenuRef}
className="md:hidden overflow-hidden border-t border-slate-800/50"
style={{ height: 0, opacity: 0 }}
aria-hidden={!mobileOpen}
>
<nav
className="max-w-6xl mx-auto px-6 py-3 flex flex-col gap-1"
aria-label="Navigation mobile"
>
{NAV_LINKS.map((link) => (
<NavLink
key={link.href}
link={link}
isActive={isActive(link.href)}
onClick={() => setMobileOpen(false)}
mobile
/>
))}
</nav>
</div>
</header>
</>
);
}
+3
View File
@@ -0,0 +1,3 @@
export { BackgroundEffects } from "./BackgroundEffects";
export { Footer } from "./Footer";
export { Header } from "./Header";
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
export interface Category {
id: string;
name: string;
color: string;
}
interface CategoryFiltersProps {
categories: Category[];
}
export function CategoryFilters({ categories }: CategoryFiltersProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const activeCategory = searchParams.get("category");
const handleFilter = (categoryId: string | null) => {
const params = new URLSearchParams(searchParams);
if (categoryId) {
params.set("category", categoryId);
} else {
params.delete("category");
}
const query = params.toString();
router.push(query ? `${pathname}?${query}` : pathname);
};
return (
<section className="max-w-6xl mx-auto px-4 sm:px-6 pb-6 sm:pb-8">
{/* Scroll horizontal sur mobile, wrap sur desktop */}
<div className="flex gap-2 overflow-x-auto pb-2 sm:pb-0 sm:flex-wrap sm:overflow-visible scrollbar-none">
<button
onClick={() => handleFilter(null)}
className={`shrink-0 cursor-pointer px-3 sm:px-4 py-1.5 sm:py-2 rounded-full font-mono text-xs sm:text-sm transition-all border
${
!activeCategory
? "bg-cyan-500/20 text-cyan-400 border-cyan-500/30"
: "bg-slate-800/50 border-slate-700/50 text-slate-400 hover:border-slate-600/50"
}`}
type="button"
>
Tous
</button>
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => handleFilter(cat.id)}
className={`shrink-0 cursor-pointer px-3 sm:px-4 py-1.5 sm:py-2 rounded-full font-mono text-xs sm:text-sm transition-all border
${
activeCategory === cat.id
? ""
: "bg-slate-800/50 border-slate-700/50 hover:border-slate-600/50"
}`}
style={{
color: cat.color,
backgroundColor:
activeCategory === cat.id
? `${cat.color}20`
: undefined,
borderColor:
activeCategory === cat.id
? `${cat.color}50`
: undefined,
}}
type="button"
>
{cat.name}
</button>
))}
</div>
</section>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { Tutorial } from "@/lib/tutorials";
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",
};
interface DifficultyBadgeProps {
level: Tutorial["difficulty"];
}
export function DifficultyBadge({ level }: DifficultyBadgeProps) {
return (
<span
className={`px-2 py-0.5 text-xs font-mono border rounded ${colors[level]}`}
>
{level}
</span>
);
}
+125
View File
@@ -0,0 +1,125 @@
import type { Tutorial } from "@/lib/tutorials";
import Link from "next/link";
import { DifficultyBadge } from "./DifficultyBadge";
interface TutorialCardProps {
tutorial: Tutorial;
}
export function TutorialCard({ tutorial }: TutorialCardProps) {
return (
<Link
href={`/tutos/${tutorial.slug}`}
className="group relative block h-full"
>
<div className="absolute -inset-px bg-linear-to-r from-cyan-500/20 via-violet-500/20 to-fuchsia-500/20 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-500 blur-sm" />
<article className="relative h-full flex flex-col bg-slate-900/80 border border-slate-700/50 rounded-lg p-6 hover:border-slate-600/50 transition-all duration-300">
{/* Terminal header */}
<div className="flex items-center gap-2 mb-4 pb-3 border-b border-slate-700/50">
<div className="flex gap-1.5">
<span className="w-3 h-3 rounded-full bg-rose-500/80" />
<span className="w-3 h-3 rounded-full bg-amber-500/80" />
<span className="w-3 h-3 rounded-full bg-emerald-500/80" />
</div>
<span className="text-slate-500 text-xs font-mono ml-2 truncate">
~/tutos/{tutorial.slug}
</span>
</div>
{/* Content */}
<div className="flex-1 flex flex-col space-y-3">
<div className="flex items-center gap-3 flex-wrap">
<span
className="font-mono text-sm px-2 py-0.5 rounded border"
style={{
backgroundColor: `${tutorial.category.color}20`,
borderColor: `${tutorial.category.color}40`,
color: tutorial.category.color,
}}
>
{tutorial.category.name}
</span>
<DifficultyBadge level={tutorial.difficulty} />
</div>
<h3 className="text-xl font-semibold text-slate-100 group-hover:text-cyan-300 transition-colors">
{tutorial.title}
</h3>
<p className="flex-1 text-slate-400 text-sm leading-relaxed line-clamp-3">
{tutorial.excerpt}
</p>
<div className="flex items-center gap-4 pt-2 text-xs text-slate-500 font-mono">
<span className="flex items-center gap-1">
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
{new Date(tutorial.date).toLocaleDateString(
"fr-FR",
{
day: "numeric",
month: "short",
year: "numeric",
}
)}
</span>
{tutorial.readTime && (
<span className="flex items-center gap-1">
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{tutorial.readTime}
</span>
)}
</div>
</div>
{/* Hover prompt */}
<div className="mt-4 pt-3 border-t border-slate-700/50 opacity-0 group-hover:opacity-100 transition-all duration-300">
<div className="flex items-center justify-between mb-2">
<span className="text-slate-300 text-sm font-medium">
Commencer la lecture
</span>
<svg
className="w-5 h-5 text-cyan-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M14 5l7 7m0 0l-7 7m7-7H3"
/>
</svg>
</div>
<div className="h-0.5 bg-slate-700/50 rounded-full overflow-hidden">
<div className="h-full w-0 group-hover:w-full bg-linear-to-r from-cyan-500 to-violet-500 transition-all duration-700 ease-out"></div>
</div>
</div>
</article>
</Link>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { Tutorial } from "@/lib/tutorials";
import { TutorialCard } from "./TutorialCard";
interface TutorialsGridProps {
tutorials: Tutorial[];
}
export function TutorialsGrid({ tutorials }: TutorialsGridProps) {
return (
<section className="max-w-6xl mx-auto px-4 sm:px-6 pb-12 sm:pb-20">
{tutorials.length === 0 ? (
<p className="text-slate-500 text-sm font-mono text-center">
Aucun tutoriel pour l&apos;instant.
</p>
) : (
<div className="grid gap-4 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{tutorials.map((tutorial) => (
<TutorialCard key={tutorial.id} tutorial={tutorial} />
))}
</div>
)}
</section>
);
}
+4
View File
@@ -0,0 +1,4 @@
export { DifficultyBadge } from "./DifficultyBadge";
export { TutorialCard } from "./TutorialCard";
export { CategoryFilters, type Category } from "./CategoryFilters";
export { TutorialsGrid } from "./TutorialsGrid";
+159
View File
@@ -0,0 +1,159 @@
"use client";
import { gsap } from "gsap";
import { useEffect, useRef, useState } from "react";
interface HeroSectionProps {
tutorialsCount: number;
categoriesCount: number;
title?: string | null;
filtered?: boolean;
}
// Effet de décryptage
function useScrambleText(
text: string,
options: {
duration?: number;
delay?: number;
chars?: string;
} = {}
) {
const [displayText, setDisplayText] = useState("");
const {
duration = 1.5,
delay = 0.3,
chars = "!@#$%^&*()_+-=[]{}|;:,.<>?/~`0123456789",
} = options;
useEffect(() => {
const finalText = text;
const length = finalText.length;
let currentIndex = 0;
// Délai initial
const startTimeout = setTimeout(() => {
const scrambleInterval = setInterval(() => {
let result = "";
for (let i = 0; i < length; i++) {
if (i < currentIndex) {
result += finalText[i];
} else if (finalText[i] === " ") {
result += " ";
} else {
// Caractère aléatoire
result +=
chars[Math.floor(Math.random() * chars.length)];
}
}
setDisplayText(result);
}, 30);
// Animation gsap pour révéler progressivement
gsap.to(
{ value: 0 },
{
value: length,
duration,
ease: "power2.inOut",
onUpdate: function () {
currentIndex = Math.floor(this.targets()[0].value);
},
onComplete: () => {
clearInterval(scrambleInterval);
setDisplayText(finalText);
},
}
);
return () => clearInterval(scrambleInterval);
}, delay * 1000);
return () => clearTimeout(startTimeout);
}, [text, duration, delay, chars]);
return displayText;
}
export function HeroSection({
tutorialsCount,
categoriesCount,
title,
filtered = false,
}: HeroSectionProps) {
const commandText = filtered
? `ls --filter="${title}"`
: "cat hello_world.txt";
const scrambledText = useScrambleText(commandText, {
duration: 1.2,
delay: 0.5,
chars: "█▓▒░!@#$%&*<>[]{}|",
});
// Animation du curseur clignotant
const cursorRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (cursorRef.current) {
gsap.to(cursorRef.current, {
opacity: 0,
duration: 0.5,
repeat: -1,
yoyo: true,
ease: "power2.inOut",
});
}
}, []);
return (
<section className="max-w-6xl mx-auto px-6 pt-16 pb-12">
<div className="space-y-4">
<div className="flex items-center gap-2 text-slate-500 font-mono text-sm">
<span className="text-emerald-400">$</span>
<span className="relative">
{scrambledText}
<span
ref={cursorRef}
className="inline-block w-2 h-4 bg-emerald-400 ml-0.5 align-middle"
/>
</span>
</div>
<h1 className="text-4xl sm:text-5xl font-bold tracking-tight">
<span className="bg-linear-to-r from-cyan-400 via-violet-400 to-fuchsia-400 bg-clip-text text-transparent">
{title || "Mes Tutoriels"}
</span>
</h1>
<p className="text-slate-400 text-lg max-w-2xl leading-relaxed">
{filtered
? `${tutorialsCount} tutoriel${
tutorialsCount > 1 ? "s" : ""
} dans cette catégorie.`
: "Une collection de guides pratiques. Apprenez à votre rythme avec des exemples concrets."}
</p>
</div>
{/* Stats */}
<div className="mt-10 flex flex-wrap gap-8">
<div className="space-y-1">
<div className="text-3xl font-bold text-slate-100">
{tutorialsCount}
</div>
<div className="text-sm text-slate-500 font-mono">
tutoriel{tutorialsCount > 1 ? "s" : ""}
{filtered ? " (filtrés)" : ""}
</div>
</div>
<div className="space-y-1">
<div className="text-3xl font-bold text-slate-100">
{categoriesCount}
</div>
<div className="text-sm text-slate-500 font-mono">
catégories
</div>
</div>
</div>
</section>
);
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { signOut } from "next-auth/react";
import { useState } from "react";
export function LogoutButton() {
const [loading, setLoading] = useState(false);
const handleLogout = async () => {
setLoading(true);
await signOut({ callbackUrl: "/" });
};
return (
<button
onClick={handleLogout}
disabled={loading}
className="cursor-pointer flex items-center gap-2 px-4 py-2 rounded-lg font-mono text-sm
bg-rose-500/10 border border-rose-500/30 text-rose-400
hover:bg-rose-500/20 hover:border-rose-500/50 transition-all
disabled:opacity-50 disabled:cursor-not-allowed"
type="button"
>
{loading ? (
<svg
className="w-4 h-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : (
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
)}
{loading ? "Déconnexion..." : "Se déconnecter"}
</button>
);
}
+23
View File
@@ -0,0 +1,23 @@
"use client";
import { ArrowLeftOutlined } from "@lineiconshq/free-icons";
import { Lineicons } from "@lineiconshq/react-lineicons";
import Link from "next/link";
export function BackButton() {
return (
<Link
href="/"
className="group inline-flex items-center gap-2 mb-8 px-4 py-2 rounded-lg font-mono text-sm
bg-slate-800/60 border border-slate-700/70
hover:border-cyan-400/60 hover:text-cyan-300 transition-all duration-200"
>
<Lineicons
icon={ArrowLeftOutlined}
size={16}
className="transition-transform duration-200 group-hover:-translate-x-1"
/>
Retour aux tutoriels
</Link>
);
}
+3
View File
@@ -0,0 +1,3 @@
export { BackButton } from "./back-button";
export { HeroSection } from "./HeroSection";
export { LogoutButton } from "./LogoutButton";
+24
View File
@@ -0,0 +1,24 @@
export const siteConfig = {
name: "Jessy David",
description: "Développeur Web Full-Stack | React, Next.js, Node.js",
url: "https://jessy-david.dev",
email: "contact@jessy-david.dev",
location: "France",
socials: {
github: {
url: "https://github.com/jessy-david-dev",
label: "GitHub",
},
linkedin: {
url: "https://linkedin.com/in/jessy-david",
label: "LinkedIn",
},
x: {
url: "https://x.com/UltraLion__",
label: "Twitter / X",
},
},
} as const;
export type SiteConfig = typeof siteConfig;
+60
View File
@@ -0,0 +1,60 @@
import NextAuth from "next-auth";
import Discord from "next-auth/providers/discord";
const ALLOWED_ADMIN_IDS = process.env.ALLOWED_DISCORD_IDS?.split(",") || [];
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Discord({
clientId: process.env.DISCORD_CLIENT_ID!,
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
authorization: {
params: {
scope: "identify",
},
},
}),
],
callbacks: {
async signIn({ account }) {
if (account?.provider === "discord") {
const discordId = account.providerAccountId;
if (!ALLOWED_ADMIN_IDS.includes(discordId)) {
return "/login?error=unauthorized";
}
}
return true;
},
async session({ session, token }) {
if (token.sub) {
session.user.id = token.sub;
}
if (token.discordId) {
session.user.discordId = token.discordId as string;
}
return session;
},
async jwt({ token, account }) {
if (account?.provider === "discord") {
token.discordId = account.providerAccountId;
}
return token;
},
},
pages: {
signIn: "/login",
error: "/login",
},
});
declare module "next-auth" {
interface Session {
user: {
id: string;
discordId?: string;
name?: string | null;
email?: string | null;
image?: string | null;
};
}
}
+18
View File
@@ -0,0 +1,18 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
+314
View File
@@ -0,0 +1,314 @@
import { prisma } from "./prisma";
export type Difficulty = "Débutant" | "Intermédiaire" | "Avancé";
export type Category = {
id: string;
name: string;
slug: string;
color: string;
};
export type Tutorial = {
id: string;
slug: string;
title: string;
categoryId: string;
category: Category;
difficulty: Difficulty;
readTime: string;
excerpt: string;
content: string;
date: string;
updatedAt: string;
};
export type TutorialInput = {
slug: string;
title: string;
categoryId: string;
difficulty: Difficulty;
readTime?: string;
excerpt?: string;
content: string;
};
/**
* Génère un slug à partir d'un titre
*/
export function generateSlug(title: string): string {
return title
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9\s-]/g, "")
.trim()
.replace(/\s+/g, "-")
.replace(/-+/g, "-");
}
// ============ TUTORIALS ============
export async function getAllTutorials(): Promise<Tutorial[]> {
const rows = await prisma.tutorial.findMany({
orderBy: { date: "desc" },
include: { category: true },
});
return rows.map((row) => ({
id: row.id,
slug: row.slug,
title: row.title,
categoryId: row.categoryId,
category: {
id: row.category.id,
name: row.category.name,
slug: row.category.slug,
color: row.category.color,
},
difficulty: row.difficulty as Difficulty,
readTime: row.readTime ?? "",
excerpt: row.excerpt ?? "",
content: row.content,
date: row.date.toISOString(),
updatedAt: row.updatedAt.toISOString(),
}));
}
export async function getTutorialBySlug(
slug: string
): Promise<Tutorial | null> {
const row = await prisma.tutorial.findUnique({
where: { slug },
include: { category: true },
});
if (!row) return null;
return {
id: row.id,
slug: row.slug,
title: row.title,
categoryId: row.categoryId,
category: {
id: row.category.id,
name: row.category.name,
slug: row.category.slug,
color: row.category.color,
},
difficulty: row.difficulty as Difficulty,
readTime: row.readTime ?? "",
excerpt: row.excerpt ?? "",
content: row.content,
date: row.date.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export async function getTutorialById(id: string): Promise<Tutorial | null> {
const row = await prisma.tutorial.findUnique({
where: { id },
include: { category: true },
});
if (!row) return null;
return {
id: row.id,
slug: row.slug,
title: row.title,
categoryId: row.categoryId,
category: {
id: row.category.id,
name: row.category.name,
slug: row.category.slug,
color: row.category.color,
},
difficulty: row.difficulty as Difficulty,
readTime: row.readTime ?? "",
excerpt: row.excerpt ?? "",
content: row.content,
date: row.date.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export async function addTutorial(data: TutorialInput): Promise<Tutorial> {
const row = await prisma.tutorial.create({
data: {
slug: data.slug,
title: data.title,
categoryId: data.categoryId,
difficulty: data.difficulty,
readTime: data.readTime || null,
excerpt: data.excerpt || null,
content: data.content,
},
include: { category: true },
});
return {
id: row.id,
slug: row.slug,
title: row.title,
categoryId: row.categoryId,
category: {
id: row.category.id,
name: row.category.name,
slug: row.category.slug,
color: row.category.color,
},
difficulty: row.difficulty as Difficulty,
readTime: row.readTime ?? "",
excerpt: row.excerpt ?? "",
content: row.content,
date: row.date.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export async function updateTutorial(
id: string,
data: Partial<TutorialInput>
): Promise<Tutorial> {
const row = await prisma.tutorial.update({
where: { id },
data: {
...(data.slug && { slug: data.slug }),
...(data.title && { title: data.title }),
...(data.categoryId && { categoryId: data.categoryId }),
...(data.difficulty && { difficulty: data.difficulty }),
...(data.readTime !== undefined && {
readTime: data.readTime || null,
}),
...(data.excerpt !== undefined && {
excerpt: data.excerpt || null,
}),
...(data.content && { content: data.content }),
},
include: { category: true },
});
return {
id: row.id,
slug: row.slug,
title: row.title,
categoryId: row.categoryId,
category: {
id: row.category.id,
name: row.category.name,
slug: row.category.slug,
color: row.category.color,
},
difficulty: row.difficulty as Difficulty,
readTime: row.readTime ?? "",
excerpt: row.excerpt ?? "",
content: row.content,
date: row.date.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export async function deleteTutorial(id: string): Promise<void> {
await prisma.tutorial.delete({
where: { id },
});
}
// ============ CATEGORIES ============
export async function getAllCategories(): Promise<Category[]> {
const rows = await prisma.category.findMany({
orderBy: { name: "asc" },
});
return rows.map((row) => ({
id: row.id,
name: row.name,
slug: row.slug,
color: row.color,
}));
}
export async function getCategoryById(id: string): Promise<Category | null> {
const row = await prisma.category.findUnique({
where: { id },
});
if (!row) return null;
return {
id: row.id,
name: row.name,
slug: row.slug,
color: row.color,
};
}
export async function addCategory(
name: string,
color?: string
): Promise<Category> {
const row = await prisma.category.create({
data: {
name,
slug: generateSlug(name),
color: color || "#06b6d4",
},
});
return {
id: row.id,
name: row.name,
slug: row.slug,
color: row.color,
};
}
export async function updateCategory(
id: string,
data: { name?: string; color?: string }
): Promise<Category> {
const row = await prisma.category.update({
where: { id },
data: {
...(data.name && {
name: data.name,
slug: generateSlug(data.name),
}),
...(data.color && { color: data.color }),
},
});
return {
id: row.id,
name: row.name,
slug: row.slug,
color: row.color,
};
}
export async function deleteCategory(id: string): Promise<void> {
// Vérifier qu'aucun tutoriel n'utilise cette catégorie
const count = await prisma.tutorial.count({
where: { categoryId: id },
});
if (count > 0) {
throw new Error(
`Impossible de supprimer: ${count} tutoriel(s) utilisent cette catégorie`
);
}
await prisma.category.delete({
where: { id },
});
}
export async function getCategoryTutorialCount(
categoryId: string
): Promise<number> {
return prisma.tutorial.count({
where: { categoryId },
});
}
+17
View File
@@ -0,0 +1,17 @@
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isOnDashboard = req.nextUrl.pathname.startsWith("/dashboard");
if (isOnDashboard && !isLoggedIn) {
return NextResponse.redirect(new URL("/login", req.url));
}
return NextResponse.next();
});
export const config = {
matcher: ["/dashboard/:path*"],
};
+12 -3
View File
@@ -1,6 +1,6 @@
{
"name": "tutos.jessy-david.dev",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -9,9 +9,17 @@
"lint": "eslint"
},
"dependencies": {
"next": "16.0.6",
"@gsap/react": "^2.1.2",
"@lineiconshq/free-icons": "^1.0.3",
"@lineiconshq/react-lineicons": "^1.0.5",
"@prisma/client": "^6.19.0",
"dotenv": "^17.2.3",
"gsap": "^3.13.0",
"next": "16.0.7",
"next-auth": "5.0.0-beta.30",
"react": "19.2.0",
"react-dom": "19.2.0"
"react-dom": "19.2.0",
"react-icons": "^5.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -20,6 +28,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.0.6",
"prisma": "^6.19.0",
"tailwindcss": "^4",
"typescript": "^5"
}
+470 -41
View File
@@ -8,15 +8,39 @@ importers:
.:
dependencies:
'@gsap/react':
specifier: ^2.1.2
version: 2.1.2(gsap@3.13.0)(react@19.2.0)
'@lineiconshq/free-icons':
specifier: ^1.0.3
version: 1.0.3
'@lineiconshq/react-lineicons':
specifier: ^1.0.5
version: 1.0.5(react@19.2.0)
'@prisma/client':
specifier: ^6.19.0
version: 6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3)
dotenv:
specifier: ^17.2.3
version: 17.2.3
gsap:
specifier: ^3.13.0
version: 3.13.0
next:
specifier: 16.0.6
version: 16.0.6(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
specifier: 16.0.7
version: 16.0.7(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
next-auth:
specifier: 5.0.0-beta.30
version: 5.0.0-beta.30(next@16.0.7(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)
react:
specifier: 19.2.0
version: 19.2.0
react-dom:
specifier: 19.2.0
version: 19.2.0(react@19.2.0)
react-icons:
specifier: ^5.5.0
version: 5.5.0(react@19.2.0)
devDependencies:
'@tailwindcss/postcss':
specifier: ^4
@@ -36,6 +60,9 @@ importers:
eslint-config-next:
specifier: 16.0.6
version: 16.0.6(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
prisma:
specifier: ^6.19.0
version: 6.19.0(typescript@5.9.3)
tailwindcss:
specifier: ^4
version: 4.1.17
@@ -49,6 +76,20 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
'@auth/core@0.41.0':
resolution: {integrity: sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
nodemailer: ^6.8.0
peerDependenciesMeta:
'@simplewebauthn/browser':
optional: true
'@simplewebauthn/server':
optional: true
nodemailer:
optional: true
'@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
@@ -163,6 +204,12 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@gsap/react@2.1.2':
resolution: {integrity: sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw==}
peerDependencies:
gsap: ^3.12.5
react: '>=17'
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -332,59 +379,72 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lineiconshq/free-icons@0.0.1':
resolution: {integrity: sha512-fnWSSjIv/4vojAKsBZXluhMRr1LIb5p85IzXK0Dh27KF9qXxz89HZJrnvXH71ZqMCP+NFzo/xh0dJsv8PT+3xg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@lineiconshq/free-icons@1.0.3':
resolution: {integrity: sha512-VFilyDa9JBFRRwsMYeeigHREzzo8ZuoV5jyn6PC4UpMreUD/XckX212ypnnw9WtBXVIGrtm7mdHuJ9RIKluA5Q==}
'@lineiconshq/react-lineicons@1.0.5':
resolution: {integrity: sha512-GfUBSMLoL7siDOmVwDTTGwGCREu7Q/g7PAzqoMII+SF6ZWQS3ZfTLupNcQkTx4R6+mTibz5OIoWHCseVF8WsMQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
'@next/env@16.0.6':
resolution: {integrity: sha512-PFTK/G/vM3UJwK5XDYMFOqt8QW42mmhSgdKDapOlCqBUAOfJN2dyOnASR/xUR/JRrro0pLohh/zOJ77xUQWQAg==}
'@next/env@16.0.7':
resolution: {integrity: sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==}
'@next/eslint-plugin-next@16.0.6':
resolution: {integrity: sha512-9INsBF3/4XL0/tON8AGsh0svnTtDMLwv3iREGWnWkewGdOnd790tguzq9rX8xwrVthPyvaBHhw1ww0GZz0jO5Q==}
'@next/swc-darwin-arm64@16.0.6':
resolution: {integrity: sha512-AGzKiPlDiui+9JcPRHLI4V9WFTTcKukhJTfK9qu3e0tz+Y/88B7vo5yZoO7UaikplJEHORzG3QaBFQfkjhnL0Q==}
'@next/swc-darwin-arm64@16.0.7':
resolution: {integrity: sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@16.0.6':
resolution: {integrity: sha512-LlLLNrK9WCIUkq2GciWDcquXYIf7vLxX8XE49gz7EncssZGL1vlHwgmURiJsUZAvk0HM1a8qb1ABDezsjAE/jw==}
'@next/swc-darwin-x64@16.0.7':
resolution: {integrity: sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@16.0.6':
resolution: {integrity: sha512-r04NzmLSGGfG8EPXKVK72N5zDNnq9pa9el78LhdtqIC3zqKh74QfKHnk24DoK4PEs6eY7sIK/CnNpt30oc59kg==}
'@next/swc-linux-arm64-gnu@16.0.7':
resolution: {integrity: sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@16.0.6':
resolution: {integrity: sha512-hfB/QV0hA7lbD1OJxp52wVDlpffUMfyxUB5ysZbb/pBC5iuhyLcEKSVQo56PFUUmUQzbMsAtUu6k2Gh9bBtWXA==}
'@next/swc-linux-arm64-musl@16.0.7':
resolution: {integrity: sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-x64-gnu@16.0.6':
resolution: {integrity: sha512-PZJushBgfvKhJBy01yXMdgL+l5XKr7uSn5jhOQXQXiH3iPT2M9iG64yHpPNGIKitKrHJInwmhPVGogZBAJOCPw==}
'@next/swc-linux-x64-gnu@16.0.7':
resolution: {integrity: sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@16.0.6':
resolution: {integrity: sha512-LqY76IojrH9yS5fyATjLzlOIOgwyzBuNRqXwVxcGfZ58DWNQSyfnLGlfF6shAEqjwlDNLh4Z+P0rnOI87Y9jEw==}
'@next/swc-linux-x64-musl@16.0.7':
resolution: {integrity: sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-win32-arm64-msvc@16.0.6':
resolution: {integrity: sha512-eIfSNNqAkj0tqKRf0u7BVjqylJCuabSrxnpSENY3YKApqwDMeAqYPmnOwmVe6DDl3Lvkbe7cJAyP6i9hQ5PmmQ==}
'@next/swc-win32-arm64-msvc@16.0.7':
resolution: {integrity: sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@16.0.6':
resolution: {integrity: sha512-QGs18P4OKdK9y2F3Th42+KGnwsc2iaThOe6jxQgP62kslUU4W+g6AzI6bdIn/pslhSfxjAMU5SjakfT5Fyo/xA==}
'@next/swc-win32-x64-msvc@16.0.7':
resolution: {integrity: sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -405,9 +465,45 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@panva/hkdf@1.2.1':
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
'@prisma/client@6.19.0':
resolution: {integrity: sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g==}
engines: {node: '>=18.18'}
peerDependencies:
prisma: '*'
typescript: '>=5.1.0'
peerDependenciesMeta:
prisma:
optional: true
typescript:
optional: true
'@prisma/config@6.19.0':
resolution: {integrity: sha512-zwCayme+NzI/WfrvFEtkFhhOaZb/hI+X8TTjzjJ252VbPxAl2hWHK5NMczmnG9sXck2lsXrxIZuK524E25UNmg==}
'@prisma/debug@6.19.0':
resolution: {integrity: sha512-8hAdGG7JmxrzFcTzXZajlQCidX0XNkMJkpqtfbLV54wC6LSSX6Vni25W/G+nAANwLnZ2TmwkfIuWetA7jJxJFA==}
'@prisma/engines-version@6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773':
resolution: {integrity: sha512-gV7uOBQfAFlWDvPJdQxMT1aSRur3a0EkU/6cfbAC5isV67tKDWUrPauyaHNpB+wN1ebM4A9jn/f4gH+3iHSYSQ==}
'@prisma/engines@6.19.0':
resolution: {integrity: sha512-pMRJ+1S6NVdXoB8QJAPIGpKZevFjxhKt0paCkRDTZiczKb7F4yTgRP8M4JdVkpQwmaD4EoJf6qA+p61godDokw==}
'@prisma/fetch-engine@6.19.0':
resolution: {integrity: sha512-OOx2Lda0DGrZ1rodADT06ZGqHzr7HY7LNMaFE2Vp8dp146uJld58sRuasdX0OiwpHgl8SqDTUKHNUyzEq7pDdQ==}
'@prisma/get-platform@6.19.0':
resolution: {integrity: sha512-ym85WDO2yDhC3fIXHWYpG3kVMBA49cL1XD2GCsCF8xbwoy2OkDQY44gEbAt2X46IQ4Apq9H6g0Ex1iFfPqEkHA==}
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -773,6 +869,14 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
c12@3.1.0:
resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==}
peerDependencies:
magicast: ^0.3.5
peerDependenciesMeta:
magicast:
optional: true
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@@ -796,6 +900,13 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
citty@0.1.6:
resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==}
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
@@ -809,6 +920,13 @@ packages:
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
confbox@0.2.2:
resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==}
consola@3.4.2:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -854,6 +972,10 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
deepmerge-ts@7.1.5:
resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==}
engines: {node: '>=16.0.0'}
define-data-property@1.1.4:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
engines: {node: '>= 0.4'}
@@ -862,6 +984,12 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
defu@6.1.4:
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -870,16 +998,31 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
dotenv@17.2.3:
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
engines: {node: '>=12'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
effect@3.18.4:
resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==}
electron-to-chromium@1.5.262:
resolution: {integrity: sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==}
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
empathic@2.0.0:
resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==}
engines: {node: '>=14'}
enhanced-resolve@5.18.3:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
engines: {node: '>=10.13.0'}
@@ -1040,6 +1183,13 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
exsolve@1.0.8:
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
fast-check@3.23.2:
resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
engines: {node: '>=8.0.0'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -1121,6 +1271,10 @@ packages:
get-tsconfig@4.13.0:
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
giget@2.0.0:
resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==}
hasBin: true
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@@ -1151,6 +1305,9 @@ packages:
graphemer@1.4.0:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
gsap@3.13.0:
resolution: {integrity: sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==}
has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'}
@@ -1321,6 +1478,9 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
jose@6.1.2:
resolution: {integrity: sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -1460,6 +1620,9 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
meilisearch@0.53.0:
resolution: {integrity: sha512-nG4VXbEOSzUmtbfsgOo+t6yX1ECEgXaT4hC0ap9MBpQGK5xwT+NWYDENYsKWR75cVaWaAqva+ok4zHlgtdXlLw==}
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@@ -1494,8 +1657,24 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
next@16.0.6:
resolution: {integrity: sha512-2zOZ/4FdaAp5hfCU/RnzARlZzBsjaTZ/XjNQmuyYLluAPM7kcrbIkdeO2SL0Ysd1vnrSgU+GwugfeWX1cUCgCg==}
next-auth@5.0.0-beta.30:
resolution: {integrity: sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
next: ^14.0.0-0 || ^15.0.0 || ^16.0.0
nodemailer: ^7.0.7
react: ^18.2.0 || ^19.0.0
peerDependenciesMeta:
'@simplewebauthn/browser':
optional: true
'@simplewebauthn/server':
optional: true
nodemailer:
optional: true
next@16.0.7:
resolution: {integrity: sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -1515,9 +1694,20 @@ packages:
sass:
optional: true
node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
nypm@0.6.2:
resolution: {integrity: sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==}
engines: {node: ^14.16.0 || >=16.10.0}
hasBin: true
oauth4webapi@3.8.3:
resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -1550,6 +1740,9 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -1581,6 +1774,12 @@ packages:
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1592,6 +1791,9 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -1604,10 +1806,28 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
preact-render-to-string@6.5.11:
resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==}
peerDependencies:
preact: '>=10'
preact@10.24.3:
resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prisma@6.19.0:
resolution: {integrity: sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw==}
engines: {node: '>=18.18'}
hasBin: true
peerDependencies:
typescript: '>=5.1.0'
peerDependenciesMeta:
typescript:
optional: true
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -1615,14 +1835,25 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
react-dom@19.2.0:
resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}
peerDependencies:
react: ^19.2.0
react-icons@5.5.0:
resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==}
peerDependencies:
react: '*'
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -1630,6 +1861,10 @@ packages:
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
engines: {node: '>=0.10.0'}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -1795,6 +2030,10 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
tinyexec@1.0.2:
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
engines: {node: '>=18'}
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
@@ -1911,6 +2150,14 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
'@auth/core@0.41.0':
dependencies:
'@panva/hkdf': 1.2.1
jose: 6.1.2
oauth4webapi: 3.8.3
preact: 10.24.3
preact-render-to-string: 6.5.11(preact@10.24.3)
'@babel/code-frame@7.27.1':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -2073,6 +2320,11 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
'@gsap/react@2.1.2(gsap@3.13.0)(react@19.2.0)':
dependencies:
gsap: 3.13.0
react: 19.2.0
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
@@ -2200,6 +2452,19 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@lineiconshq/free-icons@0.0.1(react@19.2.0)':
dependencies:
react: 19.2.0
'@lineiconshq/free-icons@1.0.3':
dependencies:
meilisearch: 0.53.0
'@lineiconshq/react-lineicons@1.0.5(react@19.2.0)':
dependencies:
'@lineiconshq/free-icons': 0.0.1(react@19.2.0)
react: 19.2.0
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.7.1
@@ -2207,34 +2472,34 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
'@next/env@16.0.6': {}
'@next/env@16.0.7': {}
'@next/eslint-plugin-next@16.0.6':
dependencies:
fast-glob: 3.3.1
'@next/swc-darwin-arm64@16.0.6':
'@next/swc-darwin-arm64@16.0.7':
optional: true
'@next/swc-darwin-x64@16.0.6':
'@next/swc-darwin-x64@16.0.7':
optional: true
'@next/swc-linux-arm64-gnu@16.0.6':
'@next/swc-linux-arm64-gnu@16.0.7':
optional: true
'@next/swc-linux-arm64-musl@16.0.6':
'@next/swc-linux-arm64-musl@16.0.7':
optional: true
'@next/swc-linux-x64-gnu@16.0.6':
'@next/swc-linux-x64-gnu@16.0.7':
optional: true
'@next/swc-linux-x64-musl@16.0.6':
'@next/swc-linux-x64-musl@16.0.7':
optional: true
'@next/swc-win32-arm64-msvc@16.0.6':
'@next/swc-win32-arm64-msvc@16.0.7':
optional: true
'@next/swc-win32-x64-msvc@16.0.6':
'@next/swc-win32-x64-msvc@16.0.7':
optional: true
'@nodelib/fs.scandir@2.1.5':
@@ -2251,8 +2516,47 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@panva/hkdf@1.2.1': {}
'@prisma/client@6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3)':
optionalDependencies:
prisma: 6.19.0(typescript@5.9.3)
typescript: 5.9.3
'@prisma/config@6.19.0':
dependencies:
c12: 3.1.0
deepmerge-ts: 7.1.5
effect: 3.18.4
empathic: 2.0.0
transitivePeerDependencies:
- magicast
'@prisma/debug@6.19.0': {}
'@prisma/engines-version@6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773': {}
'@prisma/engines@6.19.0':
dependencies:
'@prisma/debug': 6.19.0
'@prisma/engines-version': 6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773
'@prisma/fetch-engine': 6.19.0
'@prisma/get-platform': 6.19.0
'@prisma/fetch-engine@6.19.0':
dependencies:
'@prisma/debug': 6.19.0
'@prisma/engines-version': 6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773
'@prisma/get-platform': 6.19.0
'@prisma/get-platform@6.19.0':
dependencies:
'@prisma/debug': 6.19.0
'@rtsao/scc@1.1.0': {}
'@standard-schema/spec@1.0.0': {}
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@@ -2625,6 +2929,21 @@ snapshots:
node-releases: 2.0.27
update-browserslist-db: 1.1.4(browserslist@4.28.0)
c12@3.1.0:
dependencies:
chokidar: 4.0.3
confbox: 0.2.2
defu: 6.1.4
dotenv: 16.6.1
exsolve: 1.0.8
giget: 2.0.0
jiti: 2.6.1
ohash: 2.0.11
pathe: 2.0.3
perfect-debounce: 1.0.0
pkg-types: 2.3.0
rc9: 2.1.2
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@@ -2651,6 +2970,14 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
citty@0.1.6:
dependencies:
consola: 3.4.2
client-only@0.0.1: {}
color-convert@2.0.1:
@@ -2661,6 +2988,10 @@ snapshots:
concat-map@0.0.1: {}
confbox@0.2.2: {}
consola@3.4.2: {}
convert-source-map@2.0.0: {}
cross-spawn@7.0.6:
@@ -2701,6 +3032,8 @@ snapshots:
deep-is@0.1.4: {}
deepmerge-ts@7.1.5: {}
define-data-property@1.1.4:
dependencies:
es-define-property: 1.0.1
@@ -2713,22 +3046,37 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
defu@6.1.4: {}
destr@2.0.5: {}
detect-libc@2.1.2: {}
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
dotenv@16.6.1: {}
dotenv@17.2.3: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
effect@3.18.4:
dependencies:
'@standard-schema/spec': 1.0.0
fast-check: 3.23.2
electron-to-chromium@1.5.262: {}
emoji-regex@9.2.2: {}
empathic@2.0.0: {}
enhanced-resolve@5.18.3:
dependencies:
graceful-fs: 4.2.11
@@ -3042,6 +3390,12 @@ snapshots:
esutils@2.0.3: {}
exsolve@1.0.8: {}
fast-check@3.23.2:
dependencies:
pure-rand: 6.1.0
fast-deep-equal@3.1.3: {}
fast-glob@3.3.1:
@@ -3133,6 +3487,15 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
giget@2.0.0:
dependencies:
citty: 0.1.6
consola: 3.4.2
defu: 6.1.4
node-fetch-native: 1.6.7
nypm: 0.6.2
pathe: 2.0.3
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@@ -3156,6 +3519,8 @@ snapshots:
graphemer@1.4.0: {}
gsap@3.13.0: {}
has-bigints@1.1.0: {}
has-flag@4.0.0: {}
@@ -3328,6 +3693,8 @@ snapshots:
jiti@2.6.1: {}
jose@6.1.2: {}
js-tokens@4.0.0: {}
js-yaml@4.1.1:
@@ -3439,6 +3806,8 @@ snapshots:
math-intrinsics@1.1.0: {}
meilisearch@0.53.0: {}
merge2@1.4.1: {}
micromatch@4.0.8:
@@ -3464,9 +3833,15 @@ snapshots:
natural-compare@1.4.0: {}
next@16.0.6(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
next-auth@5.0.0-beta.30(next@16.0.7(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0):
dependencies:
'@next/env': 16.0.6
'@auth/core': 0.41.0
next: 16.0.7(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0
next@16.0.7(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@next/env': 16.0.7
'@swc/helpers': 0.5.15
caniuse-lite: 1.0.30001757
postcss: 8.4.31
@@ -3474,21 +3849,33 @@ snapshots:
react-dom: 19.2.0(react@19.2.0)
styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.0)
optionalDependencies:
'@next/swc-darwin-arm64': 16.0.6
'@next/swc-darwin-x64': 16.0.6
'@next/swc-linux-arm64-gnu': 16.0.6
'@next/swc-linux-arm64-musl': 16.0.6
'@next/swc-linux-x64-gnu': 16.0.6
'@next/swc-linux-x64-musl': 16.0.6
'@next/swc-win32-arm64-msvc': 16.0.6
'@next/swc-win32-x64-msvc': 16.0.6
'@next/swc-darwin-arm64': 16.0.7
'@next/swc-darwin-x64': 16.0.7
'@next/swc-linux-arm64-gnu': 16.0.7
'@next/swc-linux-arm64-musl': 16.0.7
'@next/swc-linux-x64-gnu': 16.0.7
'@next/swc-linux-x64-musl': 16.0.7
'@next/swc-win32-arm64-msvc': 16.0.7
'@next/swc-win32-x64-msvc': 16.0.7
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
node-fetch-native@1.6.7: {}
node-releases@2.0.27: {}
nypm@0.6.2:
dependencies:
citty: 0.1.6
consola: 3.4.2
pathe: 2.0.3
pkg-types: 2.3.0
tinyexec: 1.0.2
oauth4webapi@3.8.3: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -3531,6 +3918,8 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
ohash@2.0.11: {}
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -3564,12 +3953,22 @@ snapshots:
path-parse@1.0.7: {}
pathe@2.0.3: {}
perfect-debounce@1.0.0: {}
picocolors@1.1.1: {}
picomatch@2.3.1: {}
picomatch@4.0.3: {}
pkg-types@2.3.0:
dependencies:
confbox: 0.2.2
exsolve: 1.0.8
pathe: 2.0.3
possible-typed-array-names@1.1.0: {}
postcss@8.4.31:
@@ -3584,8 +3983,23 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
preact-render-to-string@6.5.11(preact@10.24.3):
dependencies:
preact: 10.24.3
preact@10.24.3: {}
prelude-ls@1.2.1: {}
prisma@6.19.0(typescript@5.9.3):
dependencies:
'@prisma/config': 6.19.0
'@prisma/engines': 6.19.0
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- magicast
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@@ -3594,17 +4008,30 @@ snapshots:
punycode@2.3.1: {}
pure-rand@6.1.0: {}
queue-microtask@1.2.3: {}
rc9@2.1.2:
dependencies:
defu: 6.1.4
destr: 2.0.5
react-dom@19.2.0(react@19.2.0):
dependencies:
react: 19.2.0
scheduler: 0.27.0
react-icons@5.5.0(react@19.2.0):
dependencies:
react: 19.2.0
react-is@16.13.1: {}
react@19.2.0: {}
readdirp@4.1.2: {}
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.8
@@ -3840,6 +4267,8 @@ snapshots:
tapable@2.3.0: {}
tinyexec@1.0.2: {}
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
Binary file not shown.
+31
View File
@@ -0,0 +1,31 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Tutorial {
id String @id @default(cuid())
slug String @unique
title String
categoryId String
category Category @relation(fields: [categoryId], references: [id])
difficulty String
readTime String?
excerpt String?
content String
date DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Category {
id String @id @default(cuid())
name String @unique
slug String @unique
color String @default("#06b6d4") // cyan-500
tutorials Tutorial[]
createdAt DateTime @default(now())
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 884 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 884 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

+11
View File
@@ -0,0 +1,11 @@
# Robots.txt pour jessy-david.dev
User-agent: *
Allow: /
# Sitemap
Sitemap: https://jessy-david.dev/sitemap.xml
# Bloquer les ressources inutiles pour le crawl
Disallow: /api/
Disallow: /_next/static/
+28
View File
@@ -0,0 +1,28 @@
{
"name": "Jessy David - Tutoriels",
"short_name": "JD Tutoriels",
"description": "Une collection de guides pratiques. Apprenez à votre rythme avec des exemples concrets.",
"start_url": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#3b82f6",
"orientation": "portrait-primary",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B