diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..142b718 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Dependencies +node_modules/ +/.pnp +.pnp.js +.yarn/install-state.gz + +# Build +/.next/ +/out/ +/build +dist/ +*.tsbuildinfo + +# Environment +.env +.env*.local + +# Debug logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Testing +/coverage + +# OS +.DS_Store +Thumbs.db + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Certificates +*.pem + +# Vercel +.vercel + +# Misc +*.log \ No newline at end of file diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..336fcdb --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,49 @@ +import NextAuth, { NextAuthOptions } from "next-auth"; +import DiscordProvider from "next-auth/providers/discord"; + +const ALLOWED_DISCORD_IDS = + process.env.ALLOWED_DISCORD_IDS?.split(",").map((id) => id.trim()) || []; +const ADMIN_DISCORD_IDS = + process.env.ADMIN_DISCORD_IDS?.split(",").map((id) => id.trim()) || []; + +export const authOptions: NextAuthOptions = { + providers: [ + DiscordProvider({ + clientId: process.env.DISCORD_CLIENT_ID!, + clientSecret: process.env.DISCORD_CLIENT_SECRET!, + }), + ], + callbacks: { + async signIn({ profile }) { + if (profile?.id && ALLOWED_DISCORD_IDS.includes(profile.id)) { + return true; + } + return "/unauthorized"; + }, + async jwt({ token, profile }) { + if (profile) { + token.discordId = profile.id; + token.isAdmin = ADMIN_DISCORD_IDS.includes( + profile.id as string + ); + } + return token; + }, + async session({ session, token }) { + if (session.user) { + session.user.discordId = token.discordId as string; + session.user.isAdmin = token.isAdmin as boolean; + } + return session; + }, + }, + pages: { + signIn: "/login", + error: "/unauthorized", + }, + secret: process.env.NEXTAUTH_SECRET, +}; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; diff --git a/app/api/questions/route.ts b/app/api/questions/route.ts new file mode 100644 index 0000000..64ab520 --- /dev/null +++ b/app/api/questions/route.ts @@ -0,0 +1,171 @@ +import { initDatabase, query } from "@/lib/db"; +import { getServerSession } from "next-auth"; +import { NextRequest, NextResponse } from "next/server"; +import { authOptions } from "../auth/[...nextauth]/route"; + +async function isAdmin() { + const session = await getServerSession(authOptions); + return session?.user?.isAdmin === true; +} + +export async function GET(request: NextRequest) { + try { + await initDatabase(); + const { searchParams } = new URL(request.url); + const mode = searchParams.get("mode"); + + let result; + if (mode) { + result = await query( + "SELECT * FROM questions WHERE mode = $1 ORDER BY order_index ASC, id ASC", + [mode] + ); + } else { + result = await query( + "SELECT * FROM questions ORDER BY mode, order_index ASC, id ASC" + ); + } + + return NextResponse.json(result.rows); + } catch (error) { + console.error("Error fetching questions:", error); + return NextResponse.json( + { error: "Failed to fetch questions" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + if (!(await isAdmin())) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 403 } + ); + } + + await initDatabase(); + const body = await request.json(); + const { category, question, mode } = body; + + if (!category || !question || !mode) { + return NextResponse.json( + { error: "Missing required fields" }, + { status: 400 } + ); + } + + const maxOrder = await query( + "SELECT COALESCE(MAX(order_index), -1) as max_order FROM questions WHERE mode = $1", + [mode] + ); + const newOrder = parseInt(maxOrder.rows[0].max_order) + 1; + + const result = await query( + "INSERT INTO questions (category, question, mode, order_index) VALUES ($1, $2, $3, $4) RETURNING *", + [category, question, mode, newOrder] + ); + + return NextResponse.json(result.rows[0], { status: 201 }); + } catch (error) { + console.error("Error creating question:", error); + return NextResponse.json( + { error: "Failed to create question" }, + { status: 500 } + ); + } +} + +export async function PATCH(request: NextRequest) { + try { + if (!(await isAdmin())) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 403 } + ); + } + + const body = await request.json(); + const { id, category, question, order_index } = body; + + if (!id) { + return NextResponse.json( + { error: "Missing question id" }, + { status: 400 } + ); + } + + const updates: string[] = []; + const args: (string | number)[] = []; + let paramIndex = 1; + + if (category !== undefined) { + updates.push(`category = $${paramIndex++}`); + args.push(category); + } + if (question !== undefined) { + updates.push(`question = $${paramIndex++}`); + args.push(question); + } + if (order_index !== undefined) { + updates.push(`order_index = $${paramIndex++}`); + args.push(order_index); + } + + if (updates.length === 0) { + return NextResponse.json( + { error: "No fields to update" }, + { status: 400 } + ); + } + + args.push(id); + + const result = await query( + `UPDATE questions SET ${updates.join( + ", " + )} WHERE id = $${paramIndex} RETURNING *`, + args + ); + + return NextResponse.json(result.rows[0]); + } catch (error) { + console.error("Error updating question:", error); + return NextResponse.json( + { error: "Failed to update question" }, + { status: 500 } + ); + } +} + +export async function DELETE(request: NextRequest) { + try { + if (!(await isAdmin())) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 403 } + ); + } + + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (!id) { + return NextResponse.json( + { error: "Missing question id" }, + { status: 400 } + ); + } + + await query("DELETE FROM questions WHERE id = $1", [id]); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error deleting question:", error); + return NextResponse.json( + { error: "Failed to delete question" }, + { status: 500 } + ); + } +} diff --git a/app/api/sessions/route.ts b/app/api/sessions/route.ts new file mode 100644 index 0000000..fad2139 --- /dev/null +++ b/app/api/sessions/route.ts @@ -0,0 +1,104 @@ +import { initDatabase, query } from "@/lib/db"; +import { SessionAnswer } from "@/types"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET() { + try { + await initDatabase(); + const sessionsResult = await query( + "SELECT * FROM sessions ORDER BY created_at DESC" + ); + + const sessions = await Promise.all( + sessionsResult.rows.map(async (session: any) => { + const answersResult = await query( + "SELECT * FROM session_answers WHERE session_id = $1 ORDER BY question_index", + [session.id] + ); + + return { + id: session.id, + mode: session.mode, + created_at: session.created_at, + exported_at: session.exported_at, + answers: answersResult.rows.map((a: any) => ({ + questionIndex: a.question_index, + category: a.category, + question: a.question, + answer: a.answer, + })), + }; + }) + ); + + return NextResponse.json(sessions); + } catch (error) { + console.error("Error fetching sessions:", error); + return NextResponse.json( + { error: "Failed to fetch sessions" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + await initDatabase(); + const body = await request.json(); + const { mode, answers } = body; + + if (!mode || !answers) { + return NextResponse.json( + { error: "Missing required fields" }, + { status: 400 } + ); + } + + const sessionResult = await query( + "INSERT INTO sessions (mode) VALUES ($1) RETURNING *", + [mode] + ); + + const sessionId = sessionResult.rows[0].id; + + for (const answer of answers as SessionAnswer[]) { + if (answer.answer.trim()) { + await query( + "INSERT INTO session_answers (session_id, question_index, category, question, answer) VALUES ($1, $2, $3, $4, $5)", + [ + sessionId, + answer.questionIndex, + answer.category, + answer.question, + answer.answer, + ] + ); + } + } + + const answersData = await query( + "SELECT * FROM session_answers WHERE session_id = $1 ORDER BY question_index", + [sessionId] + ); + + const newSession = { + id: sessionResult.rows[0].id, + mode: sessionResult.rows[0].mode, + created_at: sessionResult.rows[0].created_at, + answers: answersData.rows.map((a: any) => ({ + questionIndex: a.question_index, + category: a.category, + question: a.question, + answer: a.answer, + })), + }; + + return NextResponse.json(newSession, { status: 201 }); + } catch (error) { + console.error("Error creating session:", error); + return NextResponse.json( + { error: "Failed to create session" }, + { status: 500 } + ); + } +} diff --git a/app/api/tasks/route.ts b/app/api/tasks/route.ts new file mode 100644 index 0000000..4292380 --- /dev/null +++ b/app/api/tasks/route.ts @@ -0,0 +1,135 @@ +import { initDatabase, query } from "@/lib/db"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET() { + try { + await initDatabase(); + const result = await query( + "SELECT * FROM tasks ORDER BY created_at DESC" + ); + return NextResponse.json(result.rows); + } catch (error) { + console.error("Error fetching tasks:", error); + return NextResponse.json( + { error: "Failed to fetch tasks" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + await initDatabase(); + const body = await request.json(); + const { text, category, description } = body; + + if (!text || !category) { + return NextResponse.json( + { error: "Missing required fields" }, + { status: 400 } + ); + } + + const added = new Date().toLocaleDateString("fr-FR"); + + const result = await query( + "INSERT INTO tasks (text, description, checklist, category, status, added) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *", + [text, description || null, "[]", category, "todo", added] + ); + + return NextResponse.json(result.rows[0], { status: 201 }); + } catch (error) { + console.error("Error creating task:", error); + return NextResponse.json( + { error: "Failed to create task" }, + { status: 500 } + ); + } +} + +export async function PATCH(request: NextRequest) { + try { + const body = await request.json(); + const { id, status, category, text, description, checklist } = body; + + if (!id) { + return NextResponse.json( + { error: "Missing task id" }, + { status: 400 } + ); + } + + const updates: string[] = []; + const args: (string | number | null)[] = []; + let paramIndex = 1; + + if (status !== undefined) { + updates.push(`status = $${paramIndex++}`); + args.push(status); + } + if (category !== undefined) { + updates.push(`category = $${paramIndex++}`); + args.push(category); + } + if (text !== undefined) { + updates.push(`text = $${paramIndex++}`); + args.push(text); + } + if (description !== undefined) { + updates.push(`description = $${paramIndex++}`); + args.push(description); + } + if (checklist !== undefined) { + updates.push(`checklist = $${paramIndex++}`); + args.push(JSON.stringify(checklist)); + } + + if (updates.length === 0) { + return NextResponse.json( + { error: "No fields to update" }, + { status: 400 } + ); + } + + args.push(id); + + const result = await query( + `UPDATE tasks SET ${updates.join( + ", " + )} WHERE id = $${paramIndex} RETURNING *`, + args + ); + + return NextResponse.json(result.rows[0]); + } catch (error) { + console.error("Error updating task:", error); + return NextResponse.json( + { error: "Failed to update task" }, + { status: 500 } + ); + } +} + +export async function DELETE(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (!id) { + return NextResponse.json( + { error: "Missing task id" }, + { status: 400 } + ); + } + + await query("DELETE FROM tasks WHERE id = $1", [id]); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error deleting task:", error); + return NextResponse.json( + { error: "Failed to delete task" }, + { status: 500 } + ); + } +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..c4b37cb --- /dev/null +++ b/app/globals.css @@ -0,0 +1,37 @@ +@import "tailwindcss"; + +@layer base { + body { + background-color: #111827; + color: #ffffff; + overflow-x: hidden; + } + + textarea { + scrollbar-width: thin; + scrollbar-color: #4b5563 #1f2937; + } + + textarea::-webkit-scrollbar { + width: 8px; + } + + textarea::-webkit-scrollbar-track { + background: #1f2937; + } + + textarea::-webkit-scrollbar-thumb { + background: #4b5563; + border-radius: 4px; + } + + textarea::-webkit-scrollbar-thumb:hover { + background: #6b7280; + } +} + +@layer utilities { + .text-balance { + text-wrap: balance; + } +} diff --git a/app/icon.png b/app/icon.png new file mode 100644 index 0000000..bccb8ae Binary files /dev/null and b/app/icon.png differ diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..4987bae --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,22 @@ +import AuthProvider from "@/components/AuthProvider"; +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Brain Dump", + description: "Organise tes pensées avec Brain Dump", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..d694ee8 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { motion } from "framer-motion"; +import { signIn, useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function LoginPage() { + const { data: session, status } = useSession(); + const router = useRouter(); + + useEffect(() => { + if (session) { + router.push("/"); + } + }, [session, router]); + + if (status === "loading") { + return ( +
+
+
+ ); + } + + return ( +
+ +
+ + 🧠 + +

+ Brain Dump +

+

+ Connectez-vous pour accéder à l'application +

+
+ + signIn("discord", { callbackUrl: "/" })} + className="w-full flex items-center justify-center gap-3 bg-[#5865F2] hover:bg-[#4752C4] text-white font-semibold py-4 px-6 rounded-xl transition-colors duration-200" + > + + + + Se connecter avec Discord + + +

+ Seuls les utilisateurs autorisés peuvent accéder à cette + application. +

+
+
+ ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..d5a932d --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { motion } from "framer-motion"; +import Link from "next/link"; + +export default function NotFound() { + return ( +
+ + + 🤔 + +

404

+

+ Cette page n'existe pas +

+ + + Retour à l'accueil + + +
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..59d2354 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,228 @@ +"use client"; + +import CompletePage from "@/components/CompletePage"; +import Confetti from "@/components/Confetti"; +import HomePage from "@/components/HomePage"; +import KanbanBoard from "@/components/KanbanBoard"; +import Questionnaire from "@/components/Questionnaire"; +import QuestionsDashboard from "@/components/QuestionsDashboard"; +import { CRISIS_QUESTIONS, NORMAL_QUESTIONS } from "@/lib/constants"; +import { Question, QuestionMode, SessionAnswer, TaskCategory } from "@/types"; +import { signOut, useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +type Screen = "home" | "questions" | "complete" | "kanban" | "dashboard"; + +export default function Home() { + const { data: session, status } = useSession(); + const router = useRouter(); + const [screen, setScreen] = useState("home"); + const [mode, setMode] = useState("normal"); + const [sessionAnswers, setSessionAnswers] = useState([]); + const [showConfetti, setShowConfetti] = useState(false); + const [dbQuestions, setDbQuestions] = useState([]); + const [questionsLoaded, setQuestionsLoaded] = useState(false); + + useEffect(() => { + if (status === "unauthenticated") { + router.push("/login"); + } + }, [status, router]); + + useEffect(() => { + if (status !== "authenticated") return; + + const loadQuestions = async () => { + try { + const response = await fetch("/api/questions"); + const data = await response.json(); + setDbQuestions(data); + } catch (error) { + console.error("Error loading questions:", error); + } finally { + setQuestionsLoaded(true); + } + }; + loadQuestions(); + }, [status]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + if (screen === "questions") { + if (confirm("Abandonner le questionnaire ?")) { + setScreen("home"); + } + } else if (screen !== "home") { + setScreen("home"); + } + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [screen]); + + const startQuestionnaire = (crisisMode: boolean) => { + setMode(crisisMode ? "crisis" : "normal"); + setScreen("questions"); + }; + + const handleAddTask = async (text: string, category: TaskCategory) => { + const response = await fetch("/api/tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text, category }), + }); + + if (!response.ok) { + throw new Error("Failed to add task"); + } + }; + + const handleQuestionnaireComplete = async (answers: SessionAnswer[]) => { + setSessionAnswers(answers); + + try { + await fetch("/api/sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode, answers }), + }); + } catch (error) { + console.error("Error saving session:", error); + } + + setShowConfetti(true); + setTimeout(() => setShowConfetti(false), 3000); + setScreen("complete"); + }; + + const exportSession = () => { + const now = new Date(); + const date = now.toISOString().split("T")[0]; // 2026-01-02 + const time = now.toTimeString().split(" ")[0].replace(/:/g, "-"); // 23-24-21 + const modeLabel = mode === "crisis" ? "crise" : "normal"; + const filename = `${date}_${time}_dump_${modeLabel}.md`; + + const modeEmoji = mode === "crisis" ? "🆘" : "🌿"; + + let markdown = `# ${modeEmoji} Brain Dump - ${ + mode === "crisis" ? "CRISE" : "NORMAL" + }\n\n`; + markdown += `📅 **Date:** ${now.toLocaleString("fr-FR")}\n\n`; + + sessionAnswers.forEach((answer) => { + if (answer.answer.trim()) { + markdown += `## ${answer.category}\n**Q:** ${answer.question}\n**R:** ${answer.answer}\n\n`; + } + }); + + const blob = new Blob([markdown], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + }; + + const getQuestions = (questionMode: QuestionMode) => { + const modeQuestions = dbQuestions.filter( + (q) => q.mode === questionMode + ); + if (modeQuestions.length > 0) { + return modeQuestions; + } + return questionMode === "crisis" ? CRISIS_QUESTIONS : NORMAL_QUESTIONS; + }; + + const questions = getQuestions(mode); + + const handleDashboardBack = async () => { + try { + const response = await fetch("/api/questions"); + const data = await response.json(); + setDbQuestions(data); + } catch (error) { + console.error("Error reloading questions:", error); + } + setScreen("home"); + }; + + return ( + <> + {status === "loading" && ( +
+
+
+ )} + + {status === "authenticated" && ( + <> +
+
+ {session?.user?.image && ( + Avatar + )} + + {session?.user?.name} + +
+ +
+ + + + {screen === "home" && ( + startQuestionnaire(false)} + onStartCrisis={() => startQuestionnaire(true)} + onOpenKanban={() => setScreen("kanban")} + onOpenDashboard={() => setScreen("dashboard")} + isAdmin={session?.user?.isAdmin} + /> + )} + + {screen === "questions" && ( + setScreen("home")} + onAddTask={handleAddTask} + /> + )} + + {screen === "complete" && ( + setScreen("home")} + onGoToKanban={() => setScreen("kanban")} + onExport={exportSession} + /> + )} + + {screen === "kanban" && ( + setScreen("home")} /> + )} + + {screen === "dashboard" && ( + + )} + + )} + + ); +} diff --git a/app/unauthorized/page.tsx b/app/unauthorized/page.tsx new file mode 100644 index 0000000..9f249a2 --- /dev/null +++ b/app/unauthorized/page.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { motion } from "framer-motion"; +import Link from "next/link"; + +export default function UnauthorizedPage() { + return ( +
+ +
+ + 🚫 + +

+ Accès Refusé +

+

+ Vous n'êtes pas autorisé à accéder à cette + application. +

+

+ Cette application est réservée aux utilisateurs + autorisés uniquement. +

+
+ + + + Réessayer avec un autre compte + + +
+
+ ); +} diff --git a/components/AuthProvider.tsx b/components/AuthProvider.tsx new file mode 100644 index 0000000..205740a --- /dev/null +++ b/components/AuthProvider.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { SessionProvider } from "next-auth/react"; +import { ReactNode } from "react"; + +interface AuthProviderProps { + children: ReactNode; +} + +export default function AuthProvider({ children }: AuthProviderProps) { + return {children}; +} diff --git a/components/CompletePage.tsx b/components/CompletePage.tsx new file mode 100644 index 0000000..3d3e3e2 --- /dev/null +++ b/components/CompletePage.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { QuestionMode, SessionAnswer } from "@/types"; +import { motion } from "framer-motion"; + +interface CompletePageProps { + answers: SessionAnswer[]; + mode: QuestionMode; + onGoHome: () => void; + onGoToKanban: () => void; + onExport: () => void; +} + +export default function CompletePage({ + answers, + mode, + onGoHome, + onGoToKanban, + onExport, +}: CompletePageProps) { + const answeredCount = answers.filter((a) => a.answer.trim()).length; + + return ( +
+ + + {mode === "crisis" ? "💪" : "✨"} + + +

+ {mode === "crisis" + ? "Tu as fait le plus dur" + : "Session terminée !"} +

+ +

+ {answeredCount} réponse{answeredCount > 1 ? "s" : ""}{" "} + enregistrée + {answeredCount > 1 ? "s" : ""} +

+ +
+ + 📥 Exporter en Markdown + + + + 📋 Voir le Kanban + + + + 🏠 Retour à l'accueil + +
+
+
+ ); +} diff --git a/components/Confetti.tsx b/components/Confetti.tsx new file mode 100644 index 0000000..a1cffa4 --- /dev/null +++ b/components/Confetti.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect, useState } from "react"; + +interface ConfettiProps { + show: boolean; +} + +export default function Confetti({ show }: ConfettiProps) { + const [particles, setParticles] = useState([]); + + useEffect(() => { + if (show) { + setParticles(Array.from({ length: 50 }, (_, i) => i)); + } else { + setParticles([]); + } + }, [show]); + + return ( + + {show && ( +
+ {particles.map((i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/components/ConfirmModal.tsx b/components/ConfirmModal.tsx new file mode 100644 index 0000000..dc64f5d --- /dev/null +++ b/components/ConfirmModal.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; + +interface ConfirmModalProps { + isOpen: boolean; + title: string; + message: string; + confirmText?: string; + cancelText?: string; + confirmColor?: "red" | "green" | "blue"; + onConfirm: () => void; + onCancel: () => void; +} + +export default function ConfirmModal({ + isOpen, + title, + message, + confirmText = "Confirmer", + cancelText = "Annuler", + confirmColor = "red", + onConfirm, + onCancel, +}: ConfirmModalProps) { + const colorClasses = { + red: "bg-red-500 hover:bg-red-600", + green: "bg-green-500 hover:bg-green-600", + blue: "bg-blue-500 hover:bg-blue-600", + }[confirmColor]; + + return ( + + {isOpen && ( + + e.stopPropagation()} + > +

+ {title} +

+

{message}

+ +
+ + +
+
+
+ )} +
+ ); +} diff --git a/components/HomePage.tsx b/components/HomePage.tsx new file mode 100644 index 0000000..6d9764c --- /dev/null +++ b/components/HomePage.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { motion } from "framer-motion"; + +interface HomePageProps { + onStartNormal: () => void; + onStartCrisis: () => void; + onOpenKanban: () => void; + onOpenDashboard: () => void; + isAdmin?: boolean; +} + +export default function HomePage({ + onStartNormal, + onStartCrisis, + onOpenKanban, + onOpenDashboard, + isAdmin = false, +}: HomePageProps) { + return ( +
+ + + 🧠 + + +

+ Brain Dump +

+
+ +
+ + 🌿 Commencer doucement + + + + 🆘 Je ne vais pas bien ! + + +
+ + 📋 Kanban + + + {isAdmin && ( + + ⚙️ Questions + + )} +
+
+
+
+ ); +} diff --git a/components/KanbanBoard.tsx b/components/KanbanBoard.tsx new file mode 100644 index 0000000..66cff94 --- /dev/null +++ b/components/KanbanBoard.tsx @@ -0,0 +1,945 @@ +"use client"; + +import { CATEGORIES, STATUSES } from "@/lib/constants"; +import { KanbanView, Task, TaskCategory, TaskStatus } from "@/types"; +import { + closestCorners, + DndContext, + DragEndEvent, + DragOverEvent, + DragOverlay, + DragStartEvent, + PointerSensor, + useDroppable, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { AnimatePresence, motion } from "framer-motion"; +import { useCallback, useEffect, useState } from "react"; +import Confetti from "./Confetti"; +import ConfirmModal from "./ConfirmModal"; +import TaskDetailModal from "./TaskDetailModal"; +import Toast, { ToastData } from "./Toast"; + +interface KanbanBoardProps { + onBack: () => void; +} + +export default function KanbanBoard({ onBack }: KanbanBoardProps) { + const [tasks, setTasks] = useState([]); + const [view, setView] = useState("category"); + const [addingTask, setAddingTask] = useState(false); + const [newTask, setNewTask] = useState({ + text: "", + category: "urgent" as TaskCategory, + }); + const [loading, setLoading] = useState(true); + const [showConfetti, setShowConfetti] = useState(false); + const [showArchived, setShowArchived] = useState(false); + const [activeTask, setActiveTask] = useState(null); + const [originalTask, setOriginalTask] = useState(null); + const [selectedTask, setSelectedTask] = useState(null); + const [toasts, setToasts] = useState([]); + const [confirmModal, setConfirmModal] = useState<{ + isOpen: boolean; + title: string; + message: string; + onConfirm: () => void; + }>({ isOpen: false, title: "", message: "", onConfirm: () => {} }); + + const addToast = useCallback( + (message: string, type: ToastData["type"] = "success") => { + const id = Date.now().toString(); + setToasts((prev) => [...prev, { id, message, type }]); + }, + [] + ); + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); + + useEffect(() => { + fetchTasks(); + }, []); + + const fetchTasks = async () => { + try { + const response = await fetch("/api/tasks"); + const data = await response.json(); + setTasks(data); + } catch (error) { + console.error("Error fetching tasks:", error); + } finally { + setLoading(false); + } + }; + + const addTask = async () => { + if (!newTask.text.trim()) return; + + try { + const response = await fetch("/api/tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(newTask), + }); + + if (response.ok) { + const createdTask = await response.json(); + setTasks([createdTask, ...tasks]); + setNewTask({ text: "", category: "urgent" }); + setAddingTask(false); + addToast("Tâche créée", "success"); + } + } catch (error) { + console.error("Error adding task:", error); + addToast("Erreur lors de la création", "error"); + } + }; + + const updateTaskStatus = async (taskId: number, status: TaskStatus) => { + try { + const response = await fetch("/api/tasks", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: taskId, status }), + }); + + if (response.ok) { + const updatedTask = await response.json(); + setTasks(tasks.map((t) => (t.id === taskId ? updatedTask : t))); + } + } catch (error) { + console.error("Error updating task:", error); + } + }; + + const updateTaskCategory = async ( + taskId: number, + category: TaskCategory + ) => { + console.log("updateTaskCategory called:", { taskId, category }); + try { + const response = await fetch("/api/tasks", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: taskId, category }), + }); + + console.log("Response status:", response.status); + + if (response.ok) { + const updatedTask = await response.json(); + console.log("Updated task:", updatedTask); + setTasks(tasks.map((t) => (t.id === taskId ? updatedTask : t))); + } else { + const error = await response.json(); + console.error("API Error:", error); + } + } catch (error) { + console.error("Error updating task:", error); + } + }; + + const completeAndArchiveTask = async (taskId: number) => { + try { + const response = await fetch("/api/tasks", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: taskId, status: "archived" }), + }); + + if (response.ok) { + setShowConfetti(true); + setTimeout(() => setShowConfetti(false), 3000); + const updatedTask = await response.json(); + setTasks(tasks.map((t) => (t.id === taskId ? updatedTask : t))); + addToast("Tâche archivée 🎉", "success"); + } + } catch (error) { + console.error("Error archiving task:", error); + addToast("Erreur lors de l'archivage", "error"); + } + }; + + const restoreTask = async (taskId: number) => { + try { + const response = await fetch("/api/tasks", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: taskId, status: "todo" }), + }); + + if (response.ok) { + const updatedTask = await response.json(); + setTasks(tasks.map((t) => (t.id === taskId ? updatedTask : t))); + addToast("Tâche restaurée", "info"); + } + } catch (error) { + console.error("Error restoring task:", error); + addToast("Erreur lors de la restauration", "error"); + } + }; + + const deleteTask = async (taskId: number) => { + setConfirmModal({ + isOpen: true, + title: "Supprimer la tâche", + message: + "Es-tu sûr de vouloir supprimer cette tâche ? Cette action est irréversible.", + onConfirm: async () => { + try { + const response = await fetch(`/api/tasks?id=${taskId}`, { + method: "DELETE", + }); + + if (response.ok) { + setTasks(tasks.filter((t) => t.id !== taskId)); + addToast("Tâche supprimée", "success"); + } + } catch (error) { + console.error("Error deleting task:", error); + addToast("Erreur lors de la suppression", "error"); + } + setConfirmModal((prev) => ({ ...prev, isOpen: false })); + }, + }); + }; + + const handleDragStart = (event: DragStartEvent) => { + const { active } = event; + const task = tasks.find((t) => t.id === active.id); + if (task) { + setActiveTask(task); + setOriginalTask({ ...task }); + } + }; + + const handleDragOver = (event: DragOverEvent) => { + const { active, over } = event; + if (!over) return; + + const activeId = active.id as number; + const overId = over.id; + + if (view === "category") { + const categories = CATEGORIES.map((c) => c.key); + let targetCategory: TaskCategory | null = null; + + if (categories.includes(overId as TaskCategory)) { + targetCategory = overId as TaskCategory; + } else { + const overTask = tasks.find((t) => t.id === overId); + if (overTask) { + targetCategory = overTask.category; + } + } + + if (targetCategory) { + const activeTask = tasks.find((t) => t.id === activeId); + if (activeTask && activeTask.category !== targetCategory) { + setTasks( + tasks.map((t) => + t.id === activeId + ? { + ...t, + category: targetCategory as TaskCategory, + } + : t + ) + ); + } + } + } else { + const statuses = STATUSES.map((s) => s.key); + let targetStatus: TaskStatus | null = null; + + if (statuses.includes(overId as TaskStatus)) { + targetStatus = overId as TaskStatus; + } else { + const overTask = tasks.find((t) => t.id === overId); + if (overTask) { + targetStatus = overTask.status; + } + } + + if (targetStatus) { + const activeTask = tasks.find((t) => t.id === activeId); + if (activeTask && activeTask.status !== targetStatus) { + setTasks( + tasks.map((t) => + t.id === activeId + ? { ...t, status: targetStatus as TaskStatus } + : t + ) + ); + } + } + } + }; + + const handleDragEnd = async (event: DragEndEvent) => { + const { active, over } = event; + console.log("handleDragEnd:", { + activeId: active.id, + overId: over?.id, + originalTask, + }); + setActiveTask(null); + + if (!over || !originalTask) { + console.log("No over or originalTask, aborting"); + setOriginalTask(null); + return; + } + + const activeId = active.id as number; + const overId = over.id; + + if (view === "category") { + const categories = CATEGORIES.map((c) => c.key); + + let targetCategory: TaskCategory | null = null; + + if (categories.includes(overId as TaskCategory)) { + targetCategory = overId as TaskCategory; + } else { + const overTask = tasks.find((t) => t.id === overId); + if (overTask) { + targetCategory = overTask.category; + } + } + + console.log( + "Target category:", + targetCategory, + "Original:", + originalTask.category + ); + + if (targetCategory && originalTask.category !== targetCategory) { + console.log( + "Calling updateTaskCategory:", + activeId, + targetCategory + ); + await updateTaskCategory(activeId, targetCategory); + } + } else { + const statuses = STATUSES.map((s) => s.key); + let targetStatus: TaskStatus | null = null; + + if (statuses.includes(overId as TaskStatus)) { + targetStatus = overId as TaskStatus; + } else { + const overTask = tasks.find((t) => t.id === overId); + if (overTask) { + targetStatus = overTask.status; + } + } + + if (targetStatus) { + if (targetStatus === "done") { + await completeAndArchiveTask(activeId); + } else if (originalTask.status !== targetStatus) { + await updateTaskStatus(activeId, targetStatus); + } + } + } + + setOriginalTask(null); + }; + + const activeTasks = tasks.filter((t) => t.status !== "archived"); + const archivedTasks = tasks.filter((t) => t.status === "archived"); + + const getTasksByCategory = (category: TaskCategory) => + activeTasks.filter((t) => t.category === category); + + const getTasksByStatus = (status: TaskStatus) => + activeTasks.filter((t) => t.status === status); + + if (loading) { + return ( +
+
Chargement...
+
+ ); + } + + return ( + <> + + +
+
+
+ + ← Retour + + +

Ton Kanban

+ +
+ + setView( + view === "category" + ? "status" + : "category" + ) + } + className="bg-gray-700 hover:bg-gray-600 px-6 py-3 rounded-xl transition-colors" + > + Vue:{" "} + {view === "category" ? "Catégorie" : "Statut"} + + setShowArchived(!showArchived)} + className={`px-6 py-3 rounded-xl transition-colors ${ + showArchived + ? "bg-slate-600 hover:bg-slate-500" + : "bg-gray-700 hover:bg-gray-600" + }`} + > + 📦 Archivées ({archivedTasks.length}) + + setAddingTask(true)} + className="bg-blue-500 hover:bg-blue-600 px-6 py-3 rounded-xl transition-colors" + > + + Ajouter + +
+
+ +
+ 💡 Glisse-dépose les tâches entre les colonnes +
+ + + {showArchived && archivedTasks.length > 0 && ( + +
+

+ 📦 Tâches archivées +

+
+ {archivedTasks.map((task) => { + const cat = CATEGORIES.find( + (c) => c.key === task.category + ); + return ( + +

+ {task.text} +

+

+ {cat?.label} •{" "} + {task.added} +

+
+ + +
+
+ ); + })} +
+
+
+ )} +
+ + + {view === "category" ? ( + + ) : ( + + )} + + + {activeTask ? ( + + ) : null} + + + + {addingTask && ( + { + setAddingTask(false); + setNewTask({ text: "", category: "urgent" }); + }} + /> + )} + + + {selectedTask && ( + setSelectedTask(null)} + onUpdate={(updatedTask) => { + setTasks( + tasks.map((t) => + t.id === updatedTask.id + ? updatedTask + : t + ) + ); + setSelectedTask(updatedTask); + }} + onDelete={(taskId) => { + deleteTask(taskId); + setSelectedTask(null); + }} + onComplete={(taskId) => { + completeAndArchiveTask(taskId); + setSelectedTask(null); + }} + /> + )} + + + + + + setConfirmModal((prev) => ({ + ...prev, + isOpen: false, + })) + } + /> +
+
+ + ); +} + +function DroppableColumn({ + id, + children, + color, + label, + count, +}: { + id: string; + children: React.ReactNode; + color: string; + label: string; + count: number; +}) { + const { setNodeRef, isOver } = useDroppable({ id }); + + return ( +
+
+ {label} ({count}) +
+
+ {children} +
+
+ ); +} + +function SortableTaskCard({ + task, + onUpdateStatus, + onComplete, + onDelete, + onOpenDetail, +}: { + task: Task; + onUpdateStatus: (taskId: number, status: TaskStatus) => void; + onComplete: (taskId: number) => void; + onDelete: (taskId: number) => void; + onOpenDetail: (task: Task) => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: task.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + const cat = CATEGORIES.find((c) => c.key === task.category); + const checklistCount = task.checklist?.length || 0; + const checklistDone = task.checklist?.filter((i) => i.checked).length || 0; + + return ( +
+ onOpenDetail(task)} + > +
e.stopPropagation()} + className="cursor-grab active:cursor-grabbing mb-2 text-gray-500 hover:text-gray-300 flex items-center gap-2" + > + + Glisser +
+ +

{task.text}

+ +
+ {task.description && ( + + 📝 Description + + )} + {checklistCount > 0 && ( + + ☑️ {checklistDone}/{checklistCount} + + )} +
+ +
e.stopPropagation()} + > + {STATUSES.map((status) => ( + + ))} +
+ +

{task.added}

+ + +
+
+ ); +} + +function TaskCardOverlay({ task }: { task: Task }) { + const cat = CATEGORIES.find((c) => c.key === task.category); + + return ( +
+

{task.text}

+

{task.added}

+
+ ); +} + +function CategoryView({ + tasks, + getTasksByCategory, + updateTaskStatus, + completeAndArchiveTask, + deleteTask, + onOpenDetail, +}: { + tasks: Task[]; + getTasksByCategory: (category: TaskCategory) => Task[]; + updateTaskStatus: (taskId: number, status: TaskStatus) => void; + completeAndArchiveTask: (taskId: number) => void; + deleteTask: (taskId: number) => void; + onOpenDetail: (task: Task) => void; +}) { + return ( +
+ {CATEGORIES.map((cat) => { + const categoryTasks = getTasksByCategory(cat.key); + return ( + t.id)} + strategy={verticalListSortingStrategy} + > + + {categoryTasks.map((task) => ( + + ))} + + + ); + })} +
+ ); +} + +function StatusView({ + tasks, + getTasksByStatus, + updateTaskStatus, + completeAndArchiveTask, + deleteTask, + onOpenDetail, +}: { + tasks: Task[]; + getTasksByStatus: (status: TaskStatus) => Task[]; + updateTaskStatus: (taskId: number, status: TaskStatus) => void; + completeAndArchiveTask: (taskId: number) => void; + deleteTask: (taskId: number) => void; + onOpenDetail: (task: Task) => void; +}) { + return ( +
+ {STATUSES.map((status) => { + const statusTasks = getTasksByStatus(status.key); + return ( + t.id)} + strategy={verticalListSortingStrategy} + > + + {statusTasks.map((task) => ( + + ))} + + + ); + })} +
+ ); +} + +function AddTaskModal({ + newTask, + setNewTask, + onAdd, + onCancel, +}: { + newTask: { text: string; category: TaskCategory }; + setNewTask: (task: { text: string; category: TaskCategory }) => void; + onAdd: () => void; + onCancel: () => void; +}) { + return ( + + e.stopPropagation()} + className="bg-gray-800 rounded-2xl p-6 w-full max-w-md" + > +

Nouvelle tâche

+ +