feat: ajout structure projet + config Node 20
@@ -39,3 +39,5 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/lib/generated/prisma
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
|
"WillLuke.nextjs.addTypesOnSave": true,
|
||||||
|
"WillLuke.nextjs.hasPrompted": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Book1Outlined,
|
||||||
|
Folder1Outlined,
|
||||||
|
PlusOutlined,
|
||||||
|
} from "@lineiconshq/free-icons";
|
||||||
|
import { Lineicons } from "@lineiconshq/react-lineicons";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{
|
||||||
|
href: "/dashboard",
|
||||||
|
label: "Tutoriels",
|
||||||
|
icon: Book1Outlined,
|
||||||
|
exact: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/dashboard/nouveau",
|
||||||
|
label: "Nouveau",
|
||||||
|
icon: PlusOutlined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/dashboard/categories",
|
||||||
|
label: "Catégories",
|
||||||
|
icon: Folder1Outlined,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DashboardSidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const isActive = (href: string, exact?: boolean) => {
|
||||||
|
if (exact) return pathname === href;
|
||||||
|
return pathname.startsWith(href);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-64 min-h-[calc(100vh-65px)] border-r border-slate-800/50 bg-slate-900/30 backdrop-blur-sm sticky top-16">
|
||||||
|
<div className="p-4">
|
||||||
|
{/* Section title */}
|
||||||
|
<div className="px-4 py-2 mb-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
Gestion
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav items */}
|
||||||
|
<nav className="space-y-1">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const active = isActive(item.href, item.exact);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`group flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||||
|
active
|
||||||
|
? "bg-cyan-500/10 text-cyan-400 border-l-2 border-cyan-400 ml-[-1px]"
|
||||||
|
: "text-slate-400 hover:text-slate-100 hover:bg-slate-800/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Lineicons
|
||||||
|
icon={item.icon}
|
||||||
|
size={20}
|
||||||
|
className={`transition-transform duration-200 ${
|
||||||
|
active ? "" : "group-hover:scale-110"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
{active && (
|
||||||
|
<span className="ml-auto w-1.5 h-1.5 rounded-full bg-cyan-400" />
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="my-6 border-t border-slate-800/50" />
|
||||||
|
|
||||||
|
{/* Quick action */}
|
||||||
|
<Link
|
||||||
|
href="/dashboard/nouveau"
|
||||||
|
className="flex items-center justify-center gap-2 px-4 py-3 rounded-lg text-sm font-medium bg-gradient-to-r from-cyan-500/20 to-violet-500/20 text-slate-100 hover:from-cyan-500/30 hover:to-violet-500/30 border border-slate-700/50 hover:border-cyan-500/30 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<Lineicons icon={PlusOutlined} size={18} />
|
||||||
|
<span>Créer un tuto</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MarkdownRenderer } from "@/app/tutos/[slug]/markdown-renderer";
|
||||||
|
import type { Category, Tutorial } from "@/lib/tutorials";
|
||||||
|
import {
|
||||||
|
AlignTextLeftOutlined,
|
||||||
|
Books2Outlined,
|
||||||
|
CheckCircle1Outlined,
|
||||||
|
Code1Outlined,
|
||||||
|
EyeOutlined,
|
||||||
|
Folder1Outlined,
|
||||||
|
KeyboardOutlined,
|
||||||
|
Layers1Outlined,
|
||||||
|
Pencil1Outlined,
|
||||||
|
StopwatchOutlined,
|
||||||
|
} from "@lineiconshq/free-icons";
|
||||||
|
import { Lineicons } from "@lineiconshq/react-lineicons";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
type TutorialFormProps = {
|
||||||
|
categories: Category[];
|
||||||
|
action: (formData: FormData) => Promise<void>;
|
||||||
|
submitLabel: string;
|
||||||
|
initialData?: Partial<Tutorial>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wrapper pour simplifier l'utilisation des icônes LineIcons
|
||||||
|
function Icon({
|
||||||
|
icon,
|
||||||
|
size = 16,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return <Lineicons icon={icon} size={size} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composant pour les labels avec icône
|
||||||
|
function FormLabel({
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
required,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
required?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2 text-sm font-mono text-slate-400 mb-2">
|
||||||
|
<Icon icon={icon} size={16} className="text-cyan-500/70" />
|
||||||
|
<span>{children}</span>
|
||||||
|
{required && <span className="text-cyan-500">*</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composant input stylisé
|
||||||
|
function FormInput({
|
||||||
|
className = "",
|
||||||
|
...props
|
||||||
|
}: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
className={`w-full rounded-lg bg-slate-950/80 border border-slate-700/50 px-4 py-2.5 text-sm
|
||||||
|
text-slate-100 placeholder:text-slate-600
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-cyan-500/30 focus:border-cyan-500/50
|
||||||
|
hover:border-slate-600 transition-all duration-200
|
||||||
|
backdrop-blur-sm ${className}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composant select stylisé
|
||||||
|
function FormSelect({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
...props
|
||||||
|
}: React.SelectHTMLAttributes<HTMLSelectElement>) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
{...props}
|
||||||
|
className={`w-full rounded-lg bg-slate-950/80 border border-slate-700/50 px-4 py-2.5 text-sm
|
||||||
|
text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500/30 focus:border-cyan-500/50
|
||||||
|
hover:border-slate-600 transition-all duration-200
|
||||||
|
backdrop-blur-sm cursor-pointer ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composant textarea stylisé
|
||||||
|
function FormTextarea({
|
||||||
|
className = "",
|
||||||
|
...props
|
||||||
|
}: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
{...props}
|
||||||
|
className={`w-full rounded-lg bg-slate-950/80 border border-slate-700/50 px-4 py-3 text-sm
|
||||||
|
text-slate-100 placeholder:text-slate-600
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-cyan-500/30 focus:border-cyan-500/50
|
||||||
|
hover:border-slate-600 transition-all duration-200
|
||||||
|
backdrop-blur-sm resize-y ${className}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composant carte section
|
||||||
|
function FormSection({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
noPadding = false,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
noPadding?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative bg-gradient-to-br from-slate-900/90 to-slate-900/70
|
||||||
|
border border-slate-700/40 rounded-xl overflow-hidden
|
||||||
|
backdrop-blur-md shadow-xl shadow-black/20 ${className}`}
|
||||||
|
>
|
||||||
|
{/* Effet de brillance en haut */}
|
||||||
|
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-cyan-500/50 to-transparent" />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 px-6 py-4 border-b border-slate-700/40 bg-slate-800/30">
|
||||||
|
<div className="p-2 rounded-lg bg-cyan-500/10 border border-cyan-500/20">
|
||||||
|
<Icon icon={icon} size={16} className="text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-base font-semibold text-slate-100 tracking-wide">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{noPadding ? children : <div className="p-6">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TutorialForm({
|
||||||
|
categories,
|
||||||
|
action,
|
||||||
|
submitLabel,
|
||||||
|
initialData,
|
||||||
|
}: TutorialFormProps) {
|
||||||
|
const [content, setContent] = useState(initialData?.content || "");
|
||||||
|
const [activeTab, setActiveTab] = useState<"edit" | "preview">("edit");
|
||||||
|
const [charCount, setCharCount] = useState(content.length);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// Mise à jour du compteur de caractères
|
||||||
|
useEffect(() => {
|
||||||
|
setCharCount(content.length);
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
// Estimation du temps de lecture
|
||||||
|
const estimatedReadTime = useCallback(() => {
|
||||||
|
const words = content.trim().split(/\s+/).length;
|
||||||
|
const minutes = Math.ceil(words / 200);
|
||||||
|
return `~${minutes} min`;
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
// Raccourcis clavier pour le markdown
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const selected = content.substring(start, end);
|
||||||
|
|
||||||
|
let newContent = content;
|
||||||
|
let newStart = start;
|
||||||
|
let newEnd = end;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case "b": // Bold
|
||||||
|
e.preventDefault();
|
||||||
|
newContent =
|
||||||
|
content.substring(0, start) +
|
||||||
|
`**${selected}**` +
|
||||||
|
content.substring(end);
|
||||||
|
newStart = start + 2;
|
||||||
|
newEnd = end + 2;
|
||||||
|
break;
|
||||||
|
case "i": // Italic
|
||||||
|
e.preventDefault();
|
||||||
|
newContent =
|
||||||
|
content.substring(0, start) +
|
||||||
|
`*${selected}*` +
|
||||||
|
content.substring(end);
|
||||||
|
newStart = start + 1;
|
||||||
|
newEnd = end + 1;
|
||||||
|
break;
|
||||||
|
case "k": // Code
|
||||||
|
e.preventDefault();
|
||||||
|
newContent =
|
||||||
|
content.substring(0, start) +
|
||||||
|
`\`${selected}\`` +
|
||||||
|
content.substring(end);
|
||||||
|
newStart = start + 1;
|
||||||
|
newEnd = end + 1;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent(newContent);
|
||||||
|
// Restaurer la sélection après le re-render
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
textarea.setSelectionRange(newStart, newEnd);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[content]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={action} className="space-y-8">
|
||||||
|
{/* Hidden input pour toujours envoyer le content */}
|
||||||
|
<input type="hidden" name="content" value={content} />
|
||||||
|
|
||||||
|
{/* Métadonnées */}
|
||||||
|
<FormSection
|
||||||
|
title="Informations du tutoriel"
|
||||||
|
icon={Folder1Outlined}
|
||||||
|
>
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<FormLabel icon={AlignTextLeftOutlined} required>
|
||||||
|
Titre
|
||||||
|
</FormLabel>
|
||||||
|
<FormInput
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
defaultValue={initialData?.title}
|
||||||
|
placeholder="Mon super tutoriel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FormLabel icon={Books2Outlined} required>
|
||||||
|
Catégorie
|
||||||
|
</FormLabel>
|
||||||
|
<FormSelect
|
||||||
|
name="categoryId"
|
||||||
|
required
|
||||||
|
defaultValue={initialData?.categoryId}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
Sélectionner une catégorie...
|
||||||
|
</option>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<option key={cat.id} value={cat.id}>
|
||||||
|
{cat.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</FormSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FormLabel icon={Layers1Outlined}>Difficulté</FormLabel>
|
||||||
|
<FormSelect
|
||||||
|
name="difficulty"
|
||||||
|
defaultValue={
|
||||||
|
initialData?.difficulty || "Intermédiaire"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="Débutant">🌱 Débutant</option>
|
||||||
|
<option value="Intermédiaire">
|
||||||
|
🌿 Intermédiaire
|
||||||
|
</option>
|
||||||
|
<option value="Avancé">🌳 Avancé</option>
|
||||||
|
</FormSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FormLabel icon={StopwatchOutlined}>
|
||||||
|
Temps de lecture
|
||||||
|
</FormLabel>
|
||||||
|
<div className="relative">
|
||||||
|
<FormInput
|
||||||
|
name="readTime"
|
||||||
|
type="text"
|
||||||
|
defaultValue={initialData?.readTime}
|
||||||
|
placeholder="5 min"
|
||||||
|
/>
|
||||||
|
{content.length > 0 && (
|
||||||
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500 font-mono">
|
||||||
|
Estimé: {estimatedReadTime()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<FormLabel icon={AlignTextLeftOutlined}>Extrait</FormLabel>
|
||||||
|
<FormTextarea
|
||||||
|
name="excerpt"
|
||||||
|
rows={2}
|
||||||
|
defaultValue={initialData?.excerpt}
|
||||||
|
placeholder="Courte description qui s'affichera sur la page d'accueil..."
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-xs text-slate-500 font-mono">
|
||||||
|
Laissez vide pour générer automatiquement depuis le
|
||||||
|
contenu
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{/* Éditeur Markdown */}
|
||||||
|
<FormSection title="Contenu" icon={Code1Outlined} noPadding>
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-700/40 bg-slate-800/20">
|
||||||
|
<div className="flex">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("edit")}
|
||||||
|
className={`group flex items-center gap-2 px-6 py-3.5 text-sm font-mono transition-all duration-200 relative ${
|
||||||
|
activeTab === "edit"
|
||||||
|
? "text-cyan-400"
|
||||||
|
: "text-slate-400 hover:text-slate-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`transition-transform duration-200 ${
|
||||||
|
activeTab === "edit"
|
||||||
|
? "scale-110"
|
||||||
|
: "group-hover:scale-105"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon icon={Pencil1Outlined} size={16} />
|
||||||
|
</span>
|
||||||
|
<span>Éditer</span>
|
||||||
|
{activeTab === "edit" && (
|
||||||
|
<span className="absolute bottom-0 inset-x-0 h-0.5 bg-gradient-to-r from-cyan-500 to-cyan-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("preview")}
|
||||||
|
className={`group flex items-center gap-2 px-6 py-3.5 text-sm font-mono transition-all duration-200 relative ${
|
||||||
|
activeTab === "preview"
|
||||||
|
? "text-cyan-400"
|
||||||
|
: "text-slate-400 hover:text-slate-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`transition-transform duration-200 ${
|
||||||
|
activeTab === "preview"
|
||||||
|
? "scale-110"
|
||||||
|
: "group-hover:scale-105"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon icon={EyeOutlined} size={16} />
|
||||||
|
</span>
|
||||||
|
<span>Aperçu</span>
|
||||||
|
{activeTab === "preview" && (
|
||||||
|
<span className="absolute bottom-0 inset-x-0 h-0.5 bg-gradient-to-r from-cyan-500 to-cyan-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistiques */}
|
||||||
|
<div className="flex items-center gap-4 pr-6 text-xs font-mono text-slate-500">
|
||||||
|
<span>{charCount.toLocaleString()} caractères</span>
|
||||||
|
<span className="text-slate-700">|</span>
|
||||||
|
<span>
|
||||||
|
{content.trim().split(/\s+/).filter(Boolean).length}{" "}
|
||||||
|
mots
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
{activeTab === "edit" ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Barre d'outils markdown */}
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-slate-800/50 border border-slate-700/30">
|
||||||
|
<Icon
|
||||||
|
icon={KeyboardOutlined}
|
||||||
|
size={14}
|
||||||
|
className="text-slate-500"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-500 font-mono mr-2">
|
||||||
|
Raccourcis:
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<kbd className="px-2 py-1 text-xs font-mono bg-slate-700/50 text-slate-300 rounded border border-slate-600/50">
|
||||||
|
Ctrl+B
|
||||||
|
</kbd>
|
||||||
|
<span className="text-slate-600 text-xs">
|
||||||
|
Gras
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 ml-3">
|
||||||
|
<kbd className="px-2 py-1 text-xs font-mono bg-slate-700/50 text-slate-300 rounded border border-slate-600/50">
|
||||||
|
Ctrl+I
|
||||||
|
</kbd>
|
||||||
|
<span className="text-slate-600 text-xs">
|
||||||
|
Italique
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 ml-3">
|
||||||
|
<kbd className="px-2 py-1 text-xs font-mono bg-slate-700/50 text-slate-300 rounded border border-slate-600/50">
|
||||||
|
Ctrl+K
|
||||||
|
</kbd>
|
||||||
|
<span className="text-slate-600 text-xs">
|
||||||
|
Code
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
rows={20}
|
||||||
|
required
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="w-full rounded-lg bg-slate-950/80 border border-slate-700/50 px-4 py-3 text-sm
|
||||||
|
font-mono text-slate-100 placeholder:text-slate-600
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-cyan-500/30 focus:border-cyan-500/50
|
||||||
|
hover:border-slate-600 transition-all duration-200
|
||||||
|
resize-y min-h-[450px] leading-relaxed"
|
||||||
|
placeholder={`# Mon tutoriel
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
Voici un exemple de contenu en **Markdown**.
|
||||||
|
|
||||||
|
## Étapes
|
||||||
|
|
||||||
|
1. Première étape
|
||||||
|
2. Deuxième étape
|
||||||
|
3. Troisième étape
|
||||||
|
|
||||||
|
## Code
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
const hello = "world";
|
||||||
|
console.log(hello);
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
C'est tout pour ce tutoriel !`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-500 font-mono">
|
||||||
|
<p>
|
||||||
|
Supporte le Markdown : titres (#), gras
|
||||||
|
(**), italique (*), code (`), listes,
|
||||||
|
citations (>), liens, images...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="min-h-[450px] rounded-lg bg-slate-950/50 border border-slate-700/30 p-6 overflow-auto">
|
||||||
|
{content.trim() ? (
|
||||||
|
<MarkdownRenderer content={content} />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full py-20 text-center">
|
||||||
|
<div className="p-4 rounded-full bg-slate-800/50 mb-4">
|
||||||
|
<Icon
|
||||||
|
icon={EyeOutlined}
|
||||||
|
size={32}
|
||||||
|
className="text-slate-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-500 font-mono text-sm">
|
||||||
|
Commencez à écrire pour voir l'aperçu...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-between pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="group relative flex items-center gap-3 px-8 py-3.5 rounded-xl font-mono text-sm
|
||||||
|
bg-gradient-to-r from-cyan-500/20 to-cyan-600/20
|
||||||
|
text-cyan-400 border border-cyan-500/40
|
||||||
|
hover:border-cyan-400/70 hover:text-cyan-300
|
||||||
|
hover:from-cyan-500/30 hover:to-cyan-600/30
|
||||||
|
hover:shadow-lg hover:shadow-cyan-500/20
|
||||||
|
active:scale-[0.98]
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
transition-all duration-300"
|
||||||
|
>
|
||||||
|
{/* Effet de brillance */}
|
||||||
|
<span className="absolute inset-0 rounded-xl overflow-hidden">
|
||||||
|
<span className="absolute inset-0 bg-gradient-to-r from-transparent via-cyan-400/10 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-700" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="relative z-10">
|
||||||
|
<Icon icon={CheckCircle1Outlined} size={20} />
|
||||||
|
</span>
|
||||||
|
<span className="relative z-10">{submitLabel}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
|
addCategory,
|
||||||
|
deleteCategory,
|
||||||
|
getAllCategories,
|
||||||
|
updateCategory,
|
||||||
|
} from "@/lib/tutorials";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const ALLOWED_DISCORD_ID = "281113457833672706";
|
||||||
|
|
||||||
|
const PRESET_COLORS = [
|
||||||
|
// Bleus & Cyans
|
||||||
|
{ name: "Cyan", value: "#06b6d4" },
|
||||||
|
{ name: "Sky", value: "#0ea5e9" },
|
||||||
|
{ name: "Blue", value: "#3b82f6" },
|
||||||
|
{ name: "Indigo", value: "#6366f1" },
|
||||||
|
// Violets & Roses
|
||||||
|
{ name: "Violet", value: "#8b5cf6" },
|
||||||
|
{ name: "Purple", value: "#a855f7" },
|
||||||
|
{ name: "Fuchsia", value: "#d946ef" },
|
||||||
|
{ name: "Pink", value: "#ec4899" },
|
||||||
|
{ name: "Rose", value: "#f43f5e" },
|
||||||
|
// Chauds
|
||||||
|
{ name: "Red", value: "#ef4444" },
|
||||||
|
{ name: "Orange", value: "#f97316" },
|
||||||
|
{ name: "Amber", value: "#f59e0b" },
|
||||||
|
{ name: "Yellow", value: "#eab308" },
|
||||||
|
// Verts
|
||||||
|
{ name: "Lime", value: "#84cc16" },
|
||||||
|
{ name: "Green", value: "#22c55e" },
|
||||||
|
{ name: "Emerald", value: "#10b981" },
|
||||||
|
{ name: "Teal", value: "#14b8a6" },
|
||||||
|
// Neutres
|
||||||
|
{ name: "Slate", value: "#64748b" },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function handleAddCategory(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (
|
||||||
|
!session?.user?.discordId ||
|
||||||
|
session.user.discordId !== ALLOWED_DISCORD_ID
|
||||||
|
) {
|
||||||
|
throw new Error("Non autorisé");
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = formData.get("name")?.toString().trim();
|
||||||
|
const color = formData.get("color")?.toString() || "#06b6d4";
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
throw new Error("Le nom est obligatoire");
|
||||||
|
}
|
||||||
|
|
||||||
|
await addCategory(name, color);
|
||||||
|
revalidatePath("/dashboard/categories");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteCategory(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (
|
||||||
|
!session?.user?.discordId ||
|
||||||
|
session.user.discordId !== ALLOWED_DISCORD_ID
|
||||||
|
) {
|
||||||
|
throw new Error("Non autorisé");
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = formData.get("id")?.toString();
|
||||||
|
if (!id) throw new Error("ID manquant");
|
||||||
|
|
||||||
|
await deleteCategory(id);
|
||||||
|
revalidatePath("/dashboard/categories");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateCategory(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (
|
||||||
|
!session?.user?.discordId ||
|
||||||
|
session.user.discordId !== ALLOWED_DISCORD_ID
|
||||||
|
) {
|
||||||
|
throw new Error("Non autorisé");
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = formData.get("id")?.toString();
|
||||||
|
const name = formData.get("name")?.toString().trim();
|
||||||
|
const color = formData.get("color")?.toString();
|
||||||
|
|
||||||
|
if (!id || !name) {
|
||||||
|
throw new Error("ID et nom sont obligatoires");
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateCategory(id, { name, color });
|
||||||
|
revalidatePath("/dashboard/categories");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CategoriesPage() {
|
||||||
|
const categories = await getAllCategories();
|
||||||
|
|
||||||
|
// Compter les tutoriels par catégorie
|
||||||
|
const counts = await prisma.tutorial.groupBy({
|
||||||
|
by: ["categoryId"],
|
||||||
|
_count: true,
|
||||||
|
});
|
||||||
|
const countMap = new Map(counts.map((c) => [c.categoryId, c._count]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
<span className="bg-linear-to-r from-cyan-400 via-violet-400 to-fuchsia-400 bg-clip-text text-transparent">
|
||||||
|
Catégories
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-400 text-sm mt-1 font-mono">
|
||||||
|
{categories.length} catégorie
|
||||||
|
{categories.length > 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Formulaire d'ajout */}
|
||||||
|
<div className="bg-slate-900/80 border border-slate-700/50 rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-100 mb-4">
|
||||||
|
Nouvelle catégorie
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form
|
||||||
|
action={handleAddCategory}
|
||||||
|
className="flex flex-wrap gap-4 items-end"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-[200px] space-y-2">
|
||||||
|
<label className="block text-sm font-mono text-slate-400">
|
||||||
|
Nom
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="w-full rounded-lg bg-slate-950 border border-slate-700 px-4 py-2.5 text-sm
|
||||||
|
text-slate-100 placeholder:text-slate-500
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-cyan-500/40 focus:border-cyan-500/40"
|
||||||
|
placeholder="Next.js, React, DevOps..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-mono text-slate-400">
|
||||||
|
Couleur
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{PRESET_COLORS.map((color) => (
|
||||||
|
<label
|
||||||
|
key={color.value}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="color"
|
||||||
|
value={color.value}
|
||||||
|
defaultChecked={
|
||||||
|
color.value === "#06b6d4"
|
||||||
|
}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-full border-2 border-transparent
|
||||||
|
peer-checked:border-white peer-checked:ring-2 peer-checked:ring-offset-2
|
||||||
|
peer-checked:ring-offset-slate-900 peer-checked:ring-current
|
||||||
|
transition-all"
|
||||||
|
style={{ backgroundColor: color.value }}
|
||||||
|
title={color.name}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-6 py-2.5 rounded-lg font-mono text-sm
|
||||||
|
bg-cyan-500/20 text-cyan-400 border border-cyan-500/40
|
||||||
|
hover:border-cyan-400/80 hover:text-cyan-300 transition-colors"
|
||||||
|
>
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste des catégories */}
|
||||||
|
{categories.length === 0 ? (
|
||||||
|
<div className="bg-slate-900/80 border border-slate-700/50 rounded-lg p-12 text-center">
|
||||||
|
<p className="text-slate-500 font-mono">
|
||||||
|
Aucune catégorie pour l'instant
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-slate-900/80 border border-slate-700/50 rounded-lg overflow-hidden">
|
||||||
|
<div className="divide-y divide-slate-800/50">
|
||||||
|
{categories.map((cat) => {
|
||||||
|
const tutorialCount = countMap.get(cat.id) || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={cat.id}
|
||||||
|
className="p-4 flex items-center justify-between hover:bg-slate-800/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: cat.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-100 font-medium">
|
||||||
|
{cat.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500 text-xs font-mono ml-2">
|
||||||
|
/{cat.slug}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-500 font-mono bg-slate-800 px-2 py-0.5 rounded">
|
||||||
|
{tutorialCount} tuto
|
||||||
|
{tutorialCount > 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Edit inline (simple) */}
|
||||||
|
<form
|
||||||
|
action={handleUpdateCategory}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="id"
|
||||||
|
value={cat.id}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
defaultValue={cat.name}
|
||||||
|
className="w-32 rounded bg-slate-950 border border-slate-700 px-2 py-1 text-xs
|
||||||
|
text-slate-100 focus:outline-none focus:ring-1 focus:ring-cyan-500/40"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
name="color"
|
||||||
|
defaultValue={cat.color}
|
||||||
|
className="rounded bg-slate-950 border border-slate-700 px-2 py-1 text-xs
|
||||||
|
text-slate-100 focus:outline-none focus:ring-1 focus:ring-cyan-500/40"
|
||||||
|
>
|
||||||
|
{PRESET_COLORS.map((color) => (
|
||||||
|
<option
|
||||||
|
key={color.value}
|
||||||
|
value={color.value}
|
||||||
|
>
|
||||||
|
{color.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-2 py-1 text-xs font-mono rounded
|
||||||
|
bg-slate-800/60 border border-slate-700/70 text-slate-400
|
||||||
|
hover:border-cyan-500/50 hover:text-cyan-400 transition-colors"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<form action={handleDeleteCategory}>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="id"
|
||||||
|
value={cat.id}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={tutorialCount > 0}
|
||||||
|
className="px-2 py-1 text-xs font-mono rounded
|
||||||
|
bg-slate-800/60 border border-slate-700/70 text-slate-400
|
||||||
|
hover:border-rose-500/50 hover:text-rose-400 transition-colors
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-slate-700/70 disabled:hover:text-slate-400"
|
||||||
|
title={
|
||||||
|
tutorialCount > 0
|
||||||
|
? "Impossible de supprimer: des tutoriels utilisent cette catégorie"
|
||||||
|
: "Supprimer"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import {
|
||||||
|
generateSlug,
|
||||||
|
getAllCategories,
|
||||||
|
getTutorialById,
|
||||||
|
updateTutorial,
|
||||||
|
} from "@/lib/tutorials";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { TutorialForm } from "../../_components/tutorial-form";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const ALLOWED_DISCORD_ID = "281113457833672706";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function EditTutorialPage({ params }: Props) {
|
||||||
|
const { id } = await params;
|
||||||
|
const [tutorial, categories] = await Promise.all([
|
||||||
|
getTutorialById(id),
|
||||||
|
getAllCategories(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!tutorial) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdate(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (
|
||||||
|
!session?.user?.discordId ||
|
||||||
|
session.user.discordId !== ALLOWED_DISCORD_ID
|
||||||
|
) {
|
||||||
|
throw new Error("Non autorisé");
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = formData.get("title")?.toString().trim();
|
||||||
|
const categoryId = formData.get("categoryId")?.toString();
|
||||||
|
const difficulty =
|
||||||
|
formData.get("difficulty")?.toString() || "Intermédiaire";
|
||||||
|
const readTime = formData.get("readTime")?.toString().trim();
|
||||||
|
const excerpt = formData.get("excerpt")?.toString().trim();
|
||||||
|
const content = formData.get("content")?.toString().trim();
|
||||||
|
|
||||||
|
if (!title || !categoryId || !content) {
|
||||||
|
throw new Error("Titre, catégorie et contenu sont obligatoires.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateTutorial(id, {
|
||||||
|
slug: generateSlug(title),
|
||||||
|
title,
|
||||||
|
categoryId,
|
||||||
|
difficulty: difficulty as "Débutant" | "Intermédiaire" | "Avancé",
|
||||||
|
readTime: readTime || "5 min",
|
||||||
|
excerpt:
|
||||||
|
excerpt ||
|
||||||
|
(content.length > 180
|
||||||
|
? content.slice(0, 180) + "..."
|
||||||
|
: content),
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
revalidatePath("/");
|
||||||
|
revalidatePath(`/tutos/${tutorial.slug}`);
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
<span className="bg-linear-to-r from-cyan-400 via-violet-400 to-fuchsia-400 bg-clip-text text-transparent">
|
||||||
|
Éditer le tutoriel
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-400 text-sm mt-1 font-mono">
|
||||||
|
{tutorial.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="px-4 py-2 rounded-lg font-mono text-sm
|
||||||
|
bg-slate-800/60 border border-slate-700/70 text-slate-400
|
||||||
|
hover:border-slate-600 hover:text-slate-300 transition-colors"
|
||||||
|
>
|
||||||
|
← Retour
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TutorialForm
|
||||||
|
categories={categories}
|
||||||
|
action={handleUpdate}
|
||||||
|
submitLabel="Enregistrer les modifications"
|
||||||
|
initialData={tutorial}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { DashboardSidebar } from "./_components/DashboardSidebar";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Dashboard | Jessy David",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100 selection:bg-cyan-500/30">
|
||||||
|
{/* Effets de fond */}
|
||||||
|
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<div className="absolute top-0 left-1/4 w-96 h-96 bg-cyan-500/5 rounded-full blur-3xl" />
|
||||||
|
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-violet-500/5 rounded-full blur-3xl" />
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.015]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex">
|
||||||
|
<DashboardSidebar />
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1 p-8">
|
||||||
|
<div className="max-w-5xl mx-auto">{children}</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { addTutorial, generateSlug, getAllCategories } from "@/lib/tutorials";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { TutorialForm } from "../_components/tutorial-form";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const ALLOWED_DISCORD_ID = "281113457833672706";
|
||||||
|
|
||||||
|
async function createTutorial(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (
|
||||||
|
!session?.user?.discordId ||
|
||||||
|
session.user.discordId !== ALLOWED_DISCORD_ID
|
||||||
|
) {
|
||||||
|
throw new Error("Non autorisé");
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = formData.get("title")?.toString().trim();
|
||||||
|
const categoryId = formData.get("categoryId")?.toString();
|
||||||
|
const difficulty =
|
||||||
|
formData.get("difficulty")?.toString() || "Intermédiaire";
|
||||||
|
const readTime = formData.get("readTime")?.toString().trim();
|
||||||
|
const excerpt = formData.get("excerpt")?.toString().trim();
|
||||||
|
const content = formData.get("content")?.toString().trim();
|
||||||
|
|
||||||
|
if (!title || !categoryId || !content) {
|
||||||
|
throw new Error("Titre, catégorie et contenu sont obligatoires.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await addTutorial({
|
||||||
|
slug: generateSlug(title),
|
||||||
|
title,
|
||||||
|
categoryId,
|
||||||
|
difficulty: difficulty as "Débutant" | "Intermédiaire" | "Avancé",
|
||||||
|
readTime: readTime || "5 min",
|
||||||
|
excerpt:
|
||||||
|
excerpt ||
|
||||||
|
(content.length > 180 ? content.slice(0, 180) + "..." : content),
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
revalidatePath("/");
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function NewTutorialPage() {
|
||||||
|
const categories = await getAllCategories();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
<span className="bg-linear-to-r from-cyan-400 via-violet-400 to-fuchsia-400 bg-clip-text text-transparent">
|
||||||
|
Nouveau tutoriel
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-400 text-sm mt-1 font-mono">
|
||||||
|
Créez un nouveau tutoriel avec preview Markdown
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="px-4 py-2 rounded-lg font-mono text-sm
|
||||||
|
bg-slate-800/60 border border-slate-700/70 text-slate-400
|
||||||
|
hover:border-slate-600 hover:text-slate-300 transition-colors"
|
||||||
|
>
|
||||||
|
← Retour
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{categories.length === 0 ? (
|
||||||
|
<div className="bg-slate-900/80 border border-amber-500/30 rounded-lg p-8 text-center">
|
||||||
|
<p className="text-amber-400 font-mono mb-4">
|
||||||
|
⚠️ Vous devez d'abord créer au moins une catégorie
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/categories"
|
||||||
|
className="text-cyan-400 hover:text-cyan-300 font-mono text-sm"
|
||||||
|
>
|
||||||
|
Gérer les catégories →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TutorialForm
|
||||||
|
categories={categories}
|
||||||
|
action={createTutorial}
|
||||||
|
submitLabel="Créer le tutoriel"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { LogoutButton } from "@/components/ui/LogoutButton";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { deleteTutorial, getAllTutorials } from "@/lib/tutorials";
|
||||||
|
import {
|
||||||
|
CalendarDaysOutlined,
|
||||||
|
PenToSquareOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
Trash3Outlined,
|
||||||
|
} from "@lineiconshq/free-icons";
|
||||||
|
import { Lineicons } from "@lineiconshq/react-lineicons";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
async function handleDelete(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
throw new Error("Non autorisé");
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = formData.get("id")?.toString();
|
||||||
|
if (!id) throw new Error("ID manquant");
|
||||||
|
|
||||||
|
await deleteTutorial(id);
|
||||||
|
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
revalidatePath("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tutorials = await getAllTutorials();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
<span className="bg-linear-to-r from-cyan-400 via-violet-400 to-fuchsia-400 bg-clip-text text-transparent">
|
||||||
|
Tutoriels
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-400 text-sm mt-1 font-mono">
|
||||||
|
{tutorials.length} tutoriel
|
||||||
|
{tutorials.length > 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/dashboard/nouveau"
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg font-mono text-sm
|
||||||
|
bg-cyan-500/20 text-cyan-400 border border-cyan-500/40
|
||||||
|
hover:border-cyan-400/80 hover:text-cyan-300 transition-colors"
|
||||||
|
>
|
||||||
|
<Lineicons icon={PlusOutlined} size={16} />
|
||||||
|
Nouveau tutoriel
|
||||||
|
</Link>
|
||||||
|
<LogoutButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste */}
|
||||||
|
{tutorials.length === 0 ? (
|
||||||
|
<div className="bg-slate-900/80 border border-slate-700/50 rounded-lg p-12 text-center">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-800/50 flex items-center justify-center">
|
||||||
|
<Lineicons
|
||||||
|
icon={PlusOutlined}
|
||||||
|
size={32}
|
||||||
|
className="text-slate-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-500 font-mono mb-4">
|
||||||
|
Aucun tutoriel pour l'instant
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-slate-900/80 border border-slate-700/50 rounded-lg overflow-hidden">
|
||||||
|
{/* Table header */}
|
||||||
|
<div className="grid grid-cols-12 gap-4 px-6 py-3 bg-slate-800/50 border-b border-slate-700/50 text-xs font-mono text-slate-500 uppercase tracking-wider">
|
||||||
|
<div className="col-span-5">Titre</div>
|
||||||
|
<div className="col-span-2">Catégorie</div>
|
||||||
|
<div className="col-span-2">Difficulté</div>
|
||||||
|
<div className="col-span-1">Date</div>
|
||||||
|
<div className="col-span-2 text-right">Actions</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table body */}
|
||||||
|
<div className="divide-y divide-slate-800/50">
|
||||||
|
{tutorials.map((tuto) => (
|
||||||
|
<div
|
||||||
|
key={tuto.id}
|
||||||
|
className="grid grid-cols-12 gap-4 px-6 py-4 items-center hover:bg-slate-800/30 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="col-span-5">
|
||||||
|
<Link
|
||||||
|
href={`/tutos/${tuto.slug}`}
|
||||||
|
className="text-slate-100 hover:text-cyan-400 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
{tuto.title}
|
||||||
|
</Link>
|
||||||
|
<p className="text-xs text-slate-500 font-mono mt-0.5">
|
||||||
|
/{tuto.slug}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2">
|
||||||
|
<span
|
||||||
|
className="px-2 py-1 text-xs font-mono rounded border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${tuto.category.color}20`,
|
||||||
|
borderColor: `${tuto.category.color}40`,
|
||||||
|
color: tuto.category.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tuto.category.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2">
|
||||||
|
<DifficultyBadge level={tuto.difficulty} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-1 flex items-center gap-1.5 text-xs text-slate-500 font-mono">
|
||||||
|
<Lineicons
|
||||||
|
icon={CalendarDaysOutlined}
|
||||||
|
size={14}
|
||||||
|
className="text-slate-600"
|
||||||
|
/>
|
||||||
|
{new Date(tuto.date).toLocaleDateString(
|
||||||
|
"fr-FR",
|
||||||
|
{
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 flex items-center justify-end gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/editer/${tuto.id}`}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-mono rounded
|
||||||
|
bg-slate-800/60 border border-slate-700/70 text-slate-400
|
||||||
|
hover:border-cyan-500/50 hover:text-cyan-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Lineicons
|
||||||
|
icon={PenToSquareOutlined}
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
Éditer
|
||||||
|
</Link>
|
||||||
|
<form action={handleDelete}>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="id"
|
||||||
|
value={tuto.id}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex items-center gap-1.5 cursor-pointer px-3 py-1.5 text-xs font-mono rounded
|
||||||
|
bg-slate-800/60 border border-slate-700/70 text-slate-400
|
||||||
|
hover:border-rose-500/50 hover:text-rose-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Lineicons
|
||||||
|
icon={Trash3Outlined}
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DifficultyBadge({ level }: { level: string }) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
Débutant: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
|
||||||
|
Intermédiaire: "bg-amber-500/20 text-amber-400 border-amber-500/30",
|
||||||
|
Avancé: "bg-rose-500/20 text-rose-400 border-rose-500/30",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs font-mono border rounded ${
|
||||||
|
colors[level] || colors["Débutant"]
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{level}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,20 +1,11 @@
|
|||||||
|
import { Footer, Header } from "@/components/layout";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { Providers } from "./providers";
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Tutos | Jessy David",
|
||||||
description: "Generated by create next app",
|
description: "Mes tutoriels personnels",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -23,11 +14,13 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="fr">
|
||||||
<body
|
<body className="antialiased">
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
<Providers>
|
||||||
>
|
<Header />
|
||||||
{children}
|
{children}
|
||||||
|
<Footer />
|
||||||
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { signIn } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function loginWithDiscord() {
|
||||||
|
await signIn("discord", { redirectTo: "/dashboard" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { LoginContent } from "@/components/auth/LoginContent";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Connexion | Jessy David",
|
||||||
|
description: "Connexion via Discord pour accéder au dashboard.",
|
||||||
|
};
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
searchParams: Promise<{ error?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function LoginPage({ searchParams }: PageProps) {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
// Déjà connecté -> Redirige vers dashboard
|
||||||
|
if (session) {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await searchParams;
|
||||||
|
return <LoginContent error={error} />;
|
||||||
|
}
|
||||||
@@ -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 (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<HomeContent
|
||||||
<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">
|
tutorials={tutorials}
|
||||||
<Image
|
categories={categories}
|
||||||
className="dark:invert"
|
allTutorialsCount={allTutorials.length}
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
return <SessionProvider>{children}</SessionProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
type MarkdownRendererProps = {
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
||||||
|
const renderMarkdown = (md: string): string => {
|
||||||
|
const lines = md.split("\n");
|
||||||
|
const result: string[] = [];
|
||||||
|
let inCodeBlock = false;
|
||||||
|
let codeBlockLang = "";
|
||||||
|
let codeBlockContent: string[] = [];
|
||||||
|
let inList = false;
|
||||||
|
let listType: "ul" | "ol" | null = null;
|
||||||
|
|
||||||
|
const escapeHtml = (text: string): string => {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
};
|
||||||
|
|
||||||
|
const processInline = (text: string): string => {
|
||||||
|
let processed = text;
|
||||||
|
|
||||||
|
// Inline code (avant les autres pour éviter les conflits)
|
||||||
|
processed = processed.replace(
|
||||||
|
/`([^`]+)`/g,
|
||||||
|
'<code class="bg-slate-800 px-1.5 py-0.5 rounded text-cyan-400 text-sm font-mono">$1</code>'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bold
|
||||||
|
processed = processed.replace(
|
||||||
|
/\*\*([^*]+)\*\*/g,
|
||||||
|
'<strong class="font-semibold text-slate-100">$1</strong>'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Italic
|
||||||
|
processed = processed.replace(
|
||||||
|
/\*([^*]+)\*/g,
|
||||||
|
'<em class="italic">$1</em>'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Links
|
||||||
|
processed = processed.replace(
|
||||||
|
/\[([^\]]+)\]\(([^)]+)\)/g,
|
||||||
|
'<a href="$2" class="text-cyan-400 hover:text-cyan-300 underline underline-offset-2" target="_blank" rel="noopener noreferrer">$1</a>'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Images
|
||||||
|
processed = processed.replace(
|
||||||
|
/!\[([^\]]*)\]\(([^)]+)\)/g,
|
||||||
|
'<img src="$2" alt="$1" class="rounded-lg my-4 max-w-full" />'
|
||||||
|
);
|
||||||
|
|
||||||
|
return processed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeList = () => {
|
||||||
|
if (inList && listType) {
|
||||||
|
result.push(listType === "ul" ? "</ul>" : "</ol>");
|
||||||
|
inList = false;
|
||||||
|
listType = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
|
||||||
|
// Code block start/end
|
||||||
|
if (line.trim().startsWith("```")) {
|
||||||
|
if (!inCodeBlock) {
|
||||||
|
closeList();
|
||||||
|
inCodeBlock = true;
|
||||||
|
codeBlockLang = line.trim().slice(3).trim() || "text";
|
||||||
|
codeBlockContent = [];
|
||||||
|
} else {
|
||||||
|
// End code block
|
||||||
|
const code = escapeHtml(codeBlockContent.join("\n"));
|
||||||
|
result.push(`
|
||||||
|
<div class="relative my-6">
|
||||||
|
<div class="absolute top-0 right-0 px-3 py-1 text-xs font-mono text-slate-500 bg-slate-800/80 rounded-bl-lg rounded-tr-lg">${codeBlockLang}</div>
|
||||||
|
<pre class="bg-slate-900 border border-slate-700/50 rounded-lg p-4 overflow-x-auto"><code class="text-sm font-mono text-emerald-400 leading-relaxed">${code}</code></pre>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
inCodeBlock = false;
|
||||||
|
codeBlockLang = "";
|
||||||
|
codeBlockContent = [];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside code block
|
||||||
|
if (inCodeBlock) {
|
||||||
|
codeBlockContent.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
|
||||||
|
// Empty line
|
||||||
|
if (!trimmedLine) {
|
||||||
|
closeList();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
if (trimmedLine.startsWith("#### ")) {
|
||||||
|
closeList();
|
||||||
|
result.push(
|
||||||
|
`<h4 class="text-base font-semibold text-slate-100 mt-6 mb-2">${processInline(
|
||||||
|
escapeHtml(trimmedLine.slice(5))
|
||||||
|
)}</h4>`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (trimmedLine.startsWith("### ")) {
|
||||||
|
closeList();
|
||||||
|
result.push(
|
||||||
|
`<h3 class="text-lg font-semibold text-slate-100 mt-8 mb-3">${processInline(
|
||||||
|
escapeHtml(trimmedLine.slice(4))
|
||||||
|
)}</h3>`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (trimmedLine.startsWith("## ")) {
|
||||||
|
closeList();
|
||||||
|
result.push(
|
||||||
|
`<h2 class="text-xl font-semibold text-slate-100 mt-10 mb-4 pb-2 border-b border-slate-700/50">${processInline(
|
||||||
|
escapeHtml(trimmedLine.slice(3))
|
||||||
|
)}</h2>`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (trimmedLine.startsWith("# ")) {
|
||||||
|
closeList();
|
||||||
|
result.push(
|
||||||
|
`<h1 class="text-2xl font-bold text-slate-100 mt-10 mb-6">${processInline(
|
||||||
|
escapeHtml(trimmedLine.slice(2))
|
||||||
|
)}</h1>`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal rule
|
||||||
|
if (
|
||||||
|
trimmedLine === "---" ||
|
||||||
|
trimmedLine === "***" ||
|
||||||
|
trimmedLine === "___"
|
||||||
|
) {
|
||||||
|
closeList();
|
||||||
|
result.push('<hr class="my-8 border-slate-700/50" />');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blockquote
|
||||||
|
if (trimmedLine.startsWith("> ")) {
|
||||||
|
closeList();
|
||||||
|
result.push(
|
||||||
|
`<blockquote class="border-l-4 border-cyan-500/50 pl-4 my-4 text-slate-400 italic">${processInline(
|
||||||
|
escapeHtml(trimmedLine.slice(2))
|
||||||
|
)}</blockquote>`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unordered list
|
||||||
|
if (trimmedLine.startsWith("- ") || trimmedLine.startsWith("* ")) {
|
||||||
|
if (!inList || listType !== "ul") {
|
||||||
|
closeList();
|
||||||
|
result.push('<ul class="my-4 space-y-1">');
|
||||||
|
inList = true;
|
||||||
|
listType = "ul";
|
||||||
|
}
|
||||||
|
result.push(
|
||||||
|
`<li class="ml-6 list-disc text-slate-300">${processInline(
|
||||||
|
escapeHtml(trimmedLine.slice(2))
|
||||||
|
)}</li>`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordered list
|
||||||
|
const orderedMatch = trimmedLine.match(/^(\d+)\.\s+(.+)$/);
|
||||||
|
if (orderedMatch) {
|
||||||
|
if (!inList || listType !== "ol") {
|
||||||
|
closeList();
|
||||||
|
result.push('<ol class="my-4 space-y-1">');
|
||||||
|
inList = true;
|
||||||
|
listType = "ol";
|
||||||
|
}
|
||||||
|
result.push(
|
||||||
|
`<li class="ml-6 list-decimal text-slate-300">${processInline(
|
||||||
|
escapeHtml(orderedMatch[2])
|
||||||
|
)}</li>`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular paragraph
|
||||||
|
closeList();
|
||||||
|
result.push(
|
||||||
|
`<p class="text-slate-300 my-4 leading-relaxed">${processInline(
|
||||||
|
escapeHtml(trimmedLine)
|
||||||
|
)}</p>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close any remaining list
|
||||||
|
closeList();
|
||||||
|
|
||||||
|
// Close any unclosed code block
|
||||||
|
if (inCodeBlock && codeBlockContent.length > 0) {
|
||||||
|
const code = escapeHtml(codeBlockContent.join("\n"));
|
||||||
|
result.push(`
|
||||||
|
<div class="relative my-6">
|
||||||
|
<div class="absolute top-0 right-0 px-3 py-1 text-xs font-mono text-slate-500 bg-slate-800/80 rounded-bl-lg rounded-tr-lg">${codeBlockLang}</div>
|
||||||
|
<pre class="bg-slate-900 border border-slate-700/50 rounded-lg p-4 overflow-x-auto"><code class="text-sm font-mono text-emerald-400 leading-relaxed">${code}</code></pre>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.join("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="prose prose-invert max-w-none"
|
||||||
|
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { BackButton } from "@/components/ui";
|
||||||
|
import { getTutorialBySlug } from "@/lib/tutorials";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { MarkdownRenderer } from "./markdown-renderer";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
const tutorial = await getTutorialBySlug(slug);
|
||||||
|
|
||||||
|
if (!tutorial) {
|
||||||
|
return { title: "Tutoriel non trouvé" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${tutorial.title} | Jessy David`,
|
||||||
|
description: tutorial.excerpt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TutorialPage({ params }: Props) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const tutorial = await getTutorialBySlug(slug);
|
||||||
|
|
||||||
|
if (!tutorial) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const difficultyColors = {
|
||||||
|
Débutant: "text-emerald-400 border-emerald-500/40",
|
||||||
|
Intermédiaire: "text-amber-400 border-amber-500/40",
|
||||||
|
Avancé: "text-rose-400 border-rose-500/40",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100 selection:bg-cyan-500/30">
|
||||||
|
{/* Effets de fond */}
|
||||||
|
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<div className="absolute top-0 left-1/4 w-96 h-96 bg-cyan-500/5 rounded-full blur-3xl" />
|
||||||
|
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-violet-500/5 rounded-full blur-3xl" />
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.015]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contenu */}
|
||||||
|
<div className="relative z-10 px-6 py-12">
|
||||||
|
<article className="max-w-3xl mx-auto">
|
||||||
|
{/* Navigation retour */}
|
||||||
|
<BackButton />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<header className="mb-8 pb-6 border-b border-slate-700/50">
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 text-xs font-mono rounded border bg-slate-800/60"
|
||||||
|
style={{
|
||||||
|
borderColor: `${tutorial.category.color}40`,
|
||||||
|
color: tutorial.category.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tutorial.category.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 text-xs font-mono rounded border bg-slate-800/60 ${
|
||||||
|
difficultyColors[tutorial.difficulty]
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tutorial.difficulty}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-mono text-slate-500">
|
||||||
|
{tutorial.readTime}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold mb-4">
|
||||||
|
<span className="bg-linear-to-r from-cyan-400 via-violet-400 to-fuchsia-400 bg-clip-text text-transparent">
|
||||||
|
{tutorial.title}
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-slate-400">{tutorial.excerpt}</p>
|
||||||
|
|
||||||
|
<div className="mt-4 text-xs font-mono text-slate-500">
|
||||||
|
Publié le{" "}
|
||||||
|
{new Date(tutorial.date).toLocaleDateString(
|
||||||
|
"fr-FR",
|
||||||
|
{
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Contenu Markdown */}
|
||||||
|
<MarkdownRenderer content={tutorial.content} />
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { BackgroundEffects } from "@/components/layout";
|
||||||
|
import {
|
||||||
|
CategoryFilters,
|
||||||
|
TutorialsGrid,
|
||||||
|
type Category,
|
||||||
|
} from "@/components/tutorials";
|
||||||
|
import { HeroSection } from "@/components/ui";
|
||||||
|
import { getAllTutorials } from "@/lib/tutorials";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Tutos | Jessy David",
|
||||||
|
description: "Mes tutoriels",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function extractCategories(
|
||||||
|
tutorials: Awaited<ReturnType<typeof getAllTutorials>>
|
||||||
|
): Category[] {
|
||||||
|
const categoriesMap = new Map<string, Category>();
|
||||||
|
tutorials.forEach((t) => {
|
||||||
|
if (!categoriesMap.has(t.category.id)) {
|
||||||
|
categoriesMap.set(t.category.id, {
|
||||||
|
id: t.category.id,
|
||||||
|
name: t.category.name,
|
||||||
|
color: t.category.color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(categoriesMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
searchParams: Promise<{ category?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function TutosPage({ searchParams }: PageProps) {
|
||||||
|
const { category } = await searchParams;
|
||||||
|
const allTutorials = await getAllTutorials();
|
||||||
|
const categories = extractCategories(allTutorials);
|
||||||
|
|
||||||
|
const tutorials = category
|
||||||
|
? allTutorials.filter((t) => t.category.id === category)
|
||||||
|
: allTutorials;
|
||||||
|
|
||||||
|
const activeCategoryName = category
|
||||||
|
? categories.find((c) => c.id === category)?.name
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100 selection:bg-cyan-500/30">
|
||||||
|
<BackgroundEffects />
|
||||||
|
<div className="relative z-10">
|
||||||
|
<HeroSection
|
||||||
|
tutorialsCount={tutorials.length}
|
||||||
|
categoriesCount={categories.length}
|
||||||
|
title={activeCategoryName}
|
||||||
|
filtered={!!category}
|
||||||
|
/>
|
||||||
|
<CategoryFilters categories={categories} />
|
||||||
|
<TutorialsGrid tutorials={tutorials} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { HomeContent } from "./HomeContent";
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./layout";
|
||||||
|
export * from "./tutorials";
|
||||||
|
export * from "./ui";
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
© {new Date().getFullYear()} {siteConfig.name}.
|
||||||
|
Tous droits réservés.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { BackgroundEffects } from "./BackgroundEffects";
|
||||||
|
export { Footer } from "./Footer";
|
||||||
|
export { Header } from "./Header";
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { DifficultyBadge } from "./DifficultyBadge";
|
||||||
|
export { TutorialCard } from "./TutorialCard";
|
||||||
|
export { CategoryFilters, type Category } from "./CategoryFilters";
|
||||||
|
export { TutorialsGrid } from "./TutorialsGrid";
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { BackButton } from "./back-button";
|
||||||
|
export { HeroSection } from "./HeroSection";
|
||||||
|
export { LogoutButton } from "./LogoutButton";
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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*"],
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tutos.jessy-david.dev",
|
"name": "tutos.jessy-david.dev",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -9,9 +9,17 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "19.2.0",
|
||||||
"react-dom": "19.2.0"
|
"react-dom": "19.2.0",
|
||||||
|
"react-icons": "^5.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -20,6 +28,7 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.6",
|
"eslint-config-next": "16.0.6",
|
||||||
|
"prisma": "^6.19.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,39 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
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:
|
next:
|
||||||
specifier: 16.0.6
|
specifier: 16.0.7
|
||||||
version: 16.0.6(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
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:
|
react:
|
||||||
specifier: 19.2.0
|
specifier: 19.2.0
|
||||||
version: 19.2.0
|
version: 19.2.0
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.2.0
|
specifier: 19.2.0
|
||||||
version: 19.2.0(react@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:
|
devDependencies:
|
||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4
|
specifier: ^4
|
||||||
@@ -36,6 +60,9 @@ importers:
|
|||||||
eslint-config-next:
|
eslint-config-next:
|
||||||
specifier: 16.0.6
|
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)
|
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:
|
tailwindcss:
|
||||||
specifier: ^4
|
specifier: ^4
|
||||||
version: 4.1.17
|
version: 4.1.17
|
||||||
@@ -49,6 +76,20 @@ packages:
|
|||||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||||
engines: {node: '>=10'}
|
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':
|
'@babel/code-frame@7.27.1':
|
||||||
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
|
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -163,6 +204,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
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':
|
'@humanfs/core@0.19.1':
|
||||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
@@ -332,59 +379,72 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
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':
|
'@napi-rs/wasm-runtime@0.2.12':
|
||||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||||
|
|
||||||
'@next/env@16.0.6':
|
'@next/env@16.0.7':
|
||||||
resolution: {integrity: sha512-PFTK/G/vM3UJwK5XDYMFOqt8QW42mmhSgdKDapOlCqBUAOfJN2dyOnASR/xUR/JRrro0pLohh/zOJ77xUQWQAg==}
|
resolution: {integrity: sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==}
|
||||||
|
|
||||||
'@next/eslint-plugin-next@16.0.6':
|
'@next/eslint-plugin-next@16.0.6':
|
||||||
resolution: {integrity: sha512-9INsBF3/4XL0/tON8AGsh0svnTtDMLwv3iREGWnWkewGdOnd790tguzq9rX8xwrVthPyvaBHhw1ww0GZz0jO5Q==}
|
resolution: {integrity: sha512-9INsBF3/4XL0/tON8AGsh0svnTtDMLwv3iREGWnWkewGdOnd790tguzq9rX8xwrVthPyvaBHhw1ww0GZz0jO5Q==}
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@16.0.6':
|
'@next/swc-darwin-arm64@16.0.7':
|
||||||
resolution: {integrity: sha512-AGzKiPlDiui+9JcPRHLI4V9WFTTcKukhJTfK9qu3e0tz+Y/88B7vo5yZoO7UaikplJEHORzG3QaBFQfkjhnL0Q==}
|
resolution: {integrity: sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-darwin-x64@16.0.6':
|
'@next/swc-darwin-x64@16.0.7':
|
||||||
resolution: {integrity: sha512-LlLLNrK9WCIUkq2GciWDcquXYIf7vLxX8XE49gz7EncssZGL1vlHwgmURiJsUZAvk0HM1a8qb1ABDezsjAE/jw==}
|
resolution: {integrity: sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-gnu@16.0.6':
|
'@next/swc-linux-arm64-gnu@16.0.7':
|
||||||
resolution: {integrity: sha512-r04NzmLSGGfG8EPXKVK72N5zDNnq9pa9el78LhdtqIC3zqKh74QfKHnk24DoK4PEs6eY7sIK/CnNpt30oc59kg==}
|
resolution: {integrity: sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@16.0.6':
|
'@next/swc-linux-arm64-musl@16.0.7':
|
||||||
resolution: {integrity: sha512-hfB/QV0hA7lbD1OJxp52wVDlpffUMfyxUB5ysZbb/pBC5iuhyLcEKSVQo56PFUUmUQzbMsAtUu6k2Gh9bBtWXA==}
|
resolution: {integrity: sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@16.0.6':
|
'@next/swc-linux-x64-gnu@16.0.7':
|
||||||
resolution: {integrity: sha512-PZJushBgfvKhJBy01yXMdgL+l5XKr7uSn5jhOQXQXiH3iPT2M9iG64yHpPNGIKitKrHJInwmhPVGogZBAJOCPw==}
|
resolution: {integrity: sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@16.0.6':
|
'@next/swc-linux-x64-musl@16.0.7':
|
||||||
resolution: {integrity: sha512-LqY76IojrH9yS5fyATjLzlOIOgwyzBuNRqXwVxcGfZ58DWNQSyfnLGlfF6shAEqjwlDNLh4Z+P0rnOI87Y9jEw==}
|
resolution: {integrity: sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@16.0.6':
|
'@next/swc-win32-arm64-msvc@16.0.7':
|
||||||
resolution: {integrity: sha512-eIfSNNqAkj0tqKRf0u7BVjqylJCuabSrxnpSENY3YKApqwDMeAqYPmnOwmVe6DDl3Lvkbe7cJAyP6i9hQ5PmmQ==}
|
resolution: {integrity: sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@next/swc-win32-x64-msvc@16.0.6':
|
'@next/swc-win32-x64-msvc@16.0.7':
|
||||||
resolution: {integrity: sha512-QGs18P4OKdK9y2F3Th42+KGnwsc2iaThOe6jxQgP62kslUU4W+g6AzI6bdIn/pslhSfxjAMU5SjakfT5Fyo/xA==}
|
resolution: {integrity: sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
@@ -405,9 +465,45 @@ packages:
|
|||||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||||
engines: {node: '>=12.4.0'}
|
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':
|
'@rtsao/scc@1.1.0':
|
||||||
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
||||||
|
|
||||||
|
'@standard-schema/spec@1.0.0':
|
||||||
|
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
|
||||||
|
|
||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||||
|
|
||||||
@@ -773,6 +869,14 @@ packages:
|
|||||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||||
hasBin: true
|
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:
|
call-bind-apply-helpers@1.0.2:
|
||||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -796,6 +900,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||||
engines: {node: '>=10'}
|
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:
|
client-only@0.0.1:
|
||||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||||
|
|
||||||
@@ -809,6 +920,13 @@ packages:
|
|||||||
concat-map@0.0.1:
|
concat-map@0.0.1:
|
||||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
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:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||||
|
|
||||||
@@ -854,6 +972,10 @@ packages:
|
|||||||
deep-is@0.1.4:
|
deep-is@0.1.4:
|
||||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
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:
|
define-data-property@1.1.4:
|
||||||
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
|
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -862,6 +984,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
|
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
|
||||||
engines: {node: '>= 0.4'}
|
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:
|
detect-libc@2.1.2:
|
||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -870,16 +998,31 @@ packages:
|
|||||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
dunder-proto@1.0.1:
|
||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
effect@3.18.4:
|
||||||
|
resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==}
|
||||||
|
|
||||||
electron-to-chromium@1.5.262:
|
electron-to-chromium@1.5.262:
|
||||||
resolution: {integrity: sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==}
|
resolution: {integrity: sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==}
|
||||||
|
|
||||||
emoji-regex@9.2.2:
|
emoji-regex@9.2.2:
|
||||||
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
||||||
|
|
||||||
|
empathic@2.0.0:
|
||||||
|
resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==}
|
||||||
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
enhanced-resolve@5.18.3:
|
enhanced-resolve@5.18.3:
|
||||||
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
|
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@@ -1040,6 +1183,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
fast-deep-equal@3.1.3:
|
||||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||||
|
|
||||||
@@ -1121,6 +1271,10 @@ packages:
|
|||||||
get-tsconfig@4.13.0:
|
get-tsconfig@4.13.0:
|
||||||
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
|
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
|
||||||
|
|
||||||
|
giget@2.0.0:
|
||||||
|
resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
glob-parent@5.1.2:
|
glob-parent@5.1.2:
|
||||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -1151,6 +1305,9 @@ packages:
|
|||||||
graphemer@1.4.0:
|
graphemer@1.4.0:
|
||||||
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
|
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
|
||||||
|
|
||||||
|
gsap@3.13.0:
|
||||||
|
resolution: {integrity: sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==}
|
||||||
|
|
||||||
has-bigints@1.1.0:
|
has-bigints@1.1.0:
|
||||||
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
|
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1321,6 +1478,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jose@6.1.2:
|
||||||
|
resolution: {integrity: sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ==}
|
||||||
|
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
@@ -1460,6 +1620,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
meilisearch@0.53.0:
|
||||||
|
resolution: {integrity: sha512-nG4VXbEOSzUmtbfsgOo+t6yX1ECEgXaT4hC0ap9MBpQGK5xwT+NWYDENYsKWR75cVaWaAqva+ok4zHlgtdXlLw==}
|
||||||
|
|
||||||
merge2@1.4.1:
|
merge2@1.4.1:
|
||||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -1494,8 +1657,24 @@ packages:
|
|||||||
natural-compare@1.4.0:
|
natural-compare@1.4.0:
|
||||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||||
|
|
||||||
next@16.0.6:
|
next-auth@5.0.0-beta.30:
|
||||||
resolution: {integrity: sha512-2zOZ/4FdaAp5hfCU/RnzARlZzBsjaTZ/XjNQmuyYLluAPM7kcrbIkdeO2SL0Ysd1vnrSgU+GwugfeWX1cUCgCg==}
|
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'}
|
engines: {node: '>=20.9.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1515,9 +1694,20 @@ packages:
|
|||||||
sass:
|
sass:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
node-fetch-native@1.6.7:
|
||||||
|
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
|
||||||
|
|
||||||
node-releases@2.0.27:
|
node-releases@2.0.27:
|
||||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
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:
|
object-assign@4.1.1:
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1550,6 +1740,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
|
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
ohash@2.0.11:
|
||||||
|
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -1581,6 +1774,12 @@ packages:
|
|||||||
path-parse@1.0.7:
|
path-parse@1.0.7:
|
||||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
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:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
@@ -1592,6 +1791,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
pkg-types@2.3.0:
|
||||||
|
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
|
||||||
|
|
||||||
possible-typed-array-names@1.1.0:
|
possible-typed-array-names@1.1.0:
|
||||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1604,10 +1806,28 @@ packages:
|
|||||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
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:
|
prelude-ls@1.2.1:
|
||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
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:
|
prop-types@15.8.1:
|
||||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||||
|
|
||||||
@@ -1615,14 +1835,25 @@ packages:
|
|||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
pure-rand@6.1.0:
|
||||||
|
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
|
||||||
|
|
||||||
queue-microtask@1.2.3:
|
queue-microtask@1.2.3:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
|
rc9@2.1.2:
|
||||||
|
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
|
||||||
|
|
||||||
react-dom@19.2.0:
|
react-dom@19.2.0:
|
||||||
resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}
|
resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.0
|
react: ^19.2.0
|
||||||
|
|
||||||
|
react-icons@5.5.0:
|
||||||
|
resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '*'
|
||||||
|
|
||||||
react-is@16.13.1:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
@@ -1630,6 +1861,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
|
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
reflect.getprototypeof@1.0.10:
|
||||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1795,6 +2030,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
tinyexec@1.0.2:
|
||||||
|
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
tinyglobby@0.2.15:
|
tinyglobby@0.2.15:
|
||||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -1911,6 +2150,14 @@ snapshots:
|
|||||||
|
|
||||||
'@alloc/quick-lru@5.2.0': {}
|
'@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':
|
'@babel/code-frame@7.27.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
@@ -2073,6 +2320,11 @@ snapshots:
|
|||||||
'@eslint/core': 0.17.0
|
'@eslint/core': 0.17.0
|
||||||
levn: 0.4.1
|
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/core@0.19.1': {}
|
||||||
|
|
||||||
'@humanfs/node@0.16.7':
|
'@humanfs/node@0.16.7':
|
||||||
@@ -2200,6 +2452,19 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@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':
|
'@napi-rs/wasm-runtime@0.2.12':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/core': 1.7.1
|
'@emnapi/core': 1.7.1
|
||||||
@@ -2207,34 +2472,34 @@ snapshots:
|
|||||||
'@tybys/wasm-util': 0.10.1
|
'@tybys/wasm-util': 0.10.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/env@16.0.6': {}
|
'@next/env@16.0.7': {}
|
||||||
|
|
||||||
'@next/eslint-plugin-next@16.0.6':
|
'@next/eslint-plugin-next@16.0.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-glob: 3.3.1
|
fast-glob: 3.3.1
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@16.0.6':
|
'@next/swc-darwin-arm64@16.0.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-darwin-x64@16.0.6':
|
'@next/swc-darwin-x64@16.0.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-arm64-gnu@16.0.6':
|
'@next/swc-linux-arm64-gnu@16.0.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@16.0.6':
|
'@next/swc-linux-arm64-musl@16.0.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@16.0.6':
|
'@next/swc-linux-x64-gnu@16.0.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@16.0.6':
|
'@next/swc-linux-x64-musl@16.0.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@16.0.6':
|
'@next/swc-win32-arm64-msvc@16.0.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-win32-x64-msvc@16.0.6':
|
'@next/swc-win32-x64-msvc@16.0.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
@@ -2251,8 +2516,47 @@ snapshots:
|
|||||||
|
|
||||||
'@nolyfill/is-core-module@1.0.39': {}
|
'@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': {}
|
'@rtsao/scc@1.1.0': {}
|
||||||
|
|
||||||
|
'@standard-schema/spec@1.0.0': {}
|
||||||
|
|
||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -2625,6 +2929,21 @@ snapshots:
|
|||||||
node-releases: 2.0.27
|
node-releases: 2.0.27
|
||||||
update-browserslist-db: 1.1.4(browserslist@4.28.0)
|
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:
|
call-bind-apply-helpers@1.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
@@ -2651,6 +2970,14 @@ snapshots:
|
|||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
supports-color: 7.2.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: {}
|
client-only@0.0.1: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
@@ -2661,6 +2988,10 @@ snapshots:
|
|||||||
|
|
||||||
concat-map@0.0.1: {}
|
concat-map@0.0.1: {}
|
||||||
|
|
||||||
|
confbox@0.2.2: {}
|
||||||
|
|
||||||
|
consola@3.4.2: {}
|
||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
@@ -2701,6 +3032,8 @@ snapshots:
|
|||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
|
|
||||||
|
deepmerge-ts@7.1.5: {}
|
||||||
|
|
||||||
define-data-property@1.1.4:
|
define-data-property@1.1.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-define-property: 1.0.1
|
es-define-property: 1.0.1
|
||||||
@@ -2713,22 +3046,37 @@ snapshots:
|
|||||||
has-property-descriptors: 1.0.2
|
has-property-descriptors: 1.0.2
|
||||||
object-keys: 1.1.1
|
object-keys: 1.1.1
|
||||||
|
|
||||||
|
defu@6.1.4: {}
|
||||||
|
|
||||||
|
destr@2.0.5: {}
|
||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
doctrine@2.1.0:
|
doctrine@2.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
esutils: 2.0.3
|
esutils: 2.0.3
|
||||||
|
|
||||||
|
dotenv@16.6.1: {}
|
||||||
|
|
||||||
|
dotenv@17.2.3: {}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind-apply-helpers: 1.0.2
|
call-bind-apply-helpers: 1.0.2
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.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: {}
|
electron-to-chromium@1.5.262: {}
|
||||||
|
|
||||||
emoji-regex@9.2.2: {}
|
emoji-regex@9.2.2: {}
|
||||||
|
|
||||||
|
empathic@2.0.0: {}
|
||||||
|
|
||||||
enhanced-resolve@5.18.3:
|
enhanced-resolve@5.18.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@@ -3042,6 +3390,12 @@ snapshots:
|
|||||||
|
|
||||||
esutils@2.0.3: {}
|
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-deep-equal@3.1.3: {}
|
||||||
|
|
||||||
fast-glob@3.3.1:
|
fast-glob@3.3.1:
|
||||||
@@ -3133,6 +3487,15 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
resolve-pkg-maps: 1.0.0
|
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:
|
glob-parent@5.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
@@ -3156,6 +3519,8 @@ snapshots:
|
|||||||
|
|
||||||
graphemer@1.4.0: {}
|
graphemer@1.4.0: {}
|
||||||
|
|
||||||
|
gsap@3.13.0: {}
|
||||||
|
|
||||||
has-bigints@1.1.0: {}
|
has-bigints@1.1.0: {}
|
||||||
|
|
||||||
has-flag@4.0.0: {}
|
has-flag@4.0.0: {}
|
||||||
@@ -3328,6 +3693,8 @@ snapshots:
|
|||||||
|
|
||||||
jiti@2.6.1: {}
|
jiti@2.6.1: {}
|
||||||
|
|
||||||
|
jose@6.1.2: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
js-yaml@4.1.1:
|
js-yaml@4.1.1:
|
||||||
@@ -3439,6 +3806,8 @@ snapshots:
|
|||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
|
meilisearch@0.53.0: {}
|
||||||
|
|
||||||
merge2@1.4.1: {}
|
merge2@1.4.1: {}
|
||||||
|
|
||||||
micromatch@4.0.8:
|
micromatch@4.0.8:
|
||||||
@@ -3464,9 +3833,15 @@ snapshots:
|
|||||||
|
|
||||||
natural-compare@1.4.0: {}
|
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:
|
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
|
'@swc/helpers': 0.5.15
|
||||||
caniuse-lite: 1.0.30001757
|
caniuse-lite: 1.0.30001757
|
||||||
postcss: 8.4.31
|
postcss: 8.4.31
|
||||||
@@ -3474,21 +3849,33 @@ snapshots:
|
|||||||
react-dom: 19.2.0(react@19.2.0)
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.0)
|
styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.0)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@next/swc-darwin-arm64': 16.0.6
|
'@next/swc-darwin-arm64': 16.0.7
|
||||||
'@next/swc-darwin-x64': 16.0.6
|
'@next/swc-darwin-x64': 16.0.7
|
||||||
'@next/swc-linux-arm64-gnu': 16.0.6
|
'@next/swc-linux-arm64-gnu': 16.0.7
|
||||||
'@next/swc-linux-arm64-musl': 16.0.6
|
'@next/swc-linux-arm64-musl': 16.0.7
|
||||||
'@next/swc-linux-x64-gnu': 16.0.6
|
'@next/swc-linux-x64-gnu': 16.0.7
|
||||||
'@next/swc-linux-x64-musl': 16.0.6
|
'@next/swc-linux-x64-musl': 16.0.7
|
||||||
'@next/swc-win32-arm64-msvc': 16.0.6
|
'@next/swc-win32-arm64-msvc': 16.0.7
|
||||||
'@next/swc-win32-x64-msvc': 16.0.6
|
'@next/swc-win32-x64-msvc': 16.0.7
|
||||||
sharp: 0.34.5
|
sharp: 0.34.5
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
- babel-plugin-macros
|
- babel-plugin-macros
|
||||||
|
|
||||||
|
node-fetch-native@1.6.7: {}
|
||||||
|
|
||||||
node-releases@2.0.27: {}
|
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-assign@4.1.1: {}
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
@@ -3531,6 +3918,8 @@ snapshots:
|
|||||||
define-properties: 1.2.1
|
define-properties: 1.2.1
|
||||||
es-object-atoms: 1.1.1
|
es-object-atoms: 1.1.1
|
||||||
|
|
||||||
|
ohash@2.0.11: {}
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
deep-is: 0.1.4
|
deep-is: 0.1.4
|
||||||
@@ -3564,12 +3953,22 @@ snapshots:
|
|||||||
|
|
||||||
path-parse@1.0.7: {}
|
path-parse@1.0.7: {}
|
||||||
|
|
||||||
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
|
perfect-debounce@1.0.0: {}
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@2.3.1: {}
|
picomatch@2.3.1: {}
|
||||||
|
|
||||||
picomatch@4.0.3: {}
|
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: {}
|
possible-typed-array-names@1.1.0: {}
|
||||||
|
|
||||||
postcss@8.4.31:
|
postcss@8.4.31:
|
||||||
@@ -3584,8 +3983,23 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.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: {}
|
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:
|
prop-types@15.8.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
@@ -3594,17 +4008,30 @@ snapshots:
|
|||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
|
pure-rand@6.1.0: {}
|
||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
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):
|
react-dom@19.2.0(react@19.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
scheduler: 0.27.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-is@16.13.1: {}
|
||||||
|
|
||||||
react@19.2.0: {}
|
react@19.2.0: {}
|
||||||
|
|
||||||
|
readdirp@4.1.2: {}
|
||||||
|
|
||||||
reflect.getprototypeof@1.0.10:
|
reflect.getprototypeof@1.0.10:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.8
|
call-bind: 1.0.8
|
||||||
@@ -3840,6 +4267,8 @@ snapshots:
|
|||||||
|
|
||||||
tapable@2.3.0: {}
|
tapable@2.3.0: {}
|
||||||
|
|
||||||
|
tinyexec@1.0.2: {}
|
||||||
|
|
||||||
tinyglobby@0.2.15:
|
tinyglobby@0.2.15:
|
||||||
dependencies:
|
dependencies:
|
||||||
fdir: 6.5.0(picomatch@4.0.3)
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 884 B |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 884 B |
@@ -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 +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 |
|
After Width: | Height: | Size: 2.6 KiB |
@@ -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 |
|
After Width: | Height: | Size: 127 KiB |
@@ -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/
|
||||||
@@ -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 +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 +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 |