Enhance project setup with Prisma, new scripts, and dependencies; update README for clarity and add API routes; improve layout and styling for better user experience
This commit is contained in:
94
src/components/CandidateForm.tsx
Normal file
94
src/components/CandidateForm.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
interface CandidateFormProps {
|
||||
candidateName: string;
|
||||
candidateRole: string;
|
||||
evaluatorName: string;
|
||||
evaluationDate: string;
|
||||
templateId: string;
|
||||
templates: { id: string; name: string }[];
|
||||
onChange: (field: string, value: string) => void;
|
||||
disabled?: boolean;
|
||||
templateDisabled?: boolean;
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-700/80 px-3 py-1.5 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/50 transition-colors";
|
||||
|
||||
const labelClass = "mb-0.5 block text-xs font-medium text-zinc-600 dark:text-zinc-400";
|
||||
|
||||
export function CandidateForm({
|
||||
candidateName,
|
||||
candidateRole,
|
||||
evaluatorName,
|
||||
evaluationDate,
|
||||
templateId,
|
||||
templates,
|
||||
onChange,
|
||||
disabled,
|
||||
templateDisabled,
|
||||
}: CandidateFormProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-x-6 gap-y-3">
|
||||
<div className="min-w-[140px]">
|
||||
<label className={labelClass}>Candidat</label>
|
||||
<input
|
||||
type="text"
|
||||
value={candidateName}
|
||||
onChange={(e) => onChange("candidateName", e.target.value)}
|
||||
className={inputClass}
|
||||
disabled={disabled}
|
||||
placeholder="Alice Chen"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[140px]">
|
||||
<label className={labelClass}>Rôle</label>
|
||||
<input
|
||||
type="text"
|
||||
value={candidateRole}
|
||||
onChange={(e) => onChange("candidateRole", e.target.value)}
|
||||
className={inputClass}
|
||||
disabled={disabled}
|
||||
placeholder="ML Engineer"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[120px]">
|
||||
<label className={labelClass}>Évaluateur</label>
|
||||
<input
|
||||
type="text"
|
||||
value={evaluatorName}
|
||||
onChange={(e) => onChange("evaluatorName", e.target.value)}
|
||||
className={inputClass}
|
||||
disabled={disabled}
|
||||
placeholder="Jean D."
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[120px]">
|
||||
<label className={labelClass}>Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={evaluationDate}
|
||||
onChange={(e) => onChange("evaluationDate", e.target.value)}
|
||||
className={inputClass}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[160px]">
|
||||
<label className={labelClass}>Modèle</label>
|
||||
<select
|
||||
value={templateId}
|
||||
onChange={(e) => onChange("templateId", e.target.value)}
|
||||
className={inputClass}
|
||||
disabled={disabled || templateDisabled}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{templates.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/components/ConfirmModal.tsx
Normal file
64
src/components/ConfirmModal.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: "danger" | "default";
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Confirmer",
|
||||
cancelLabel = "Annuler",
|
||||
variant = "default",
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40 bg-black/60" onClick={onCancel} aria-hidden="true" />
|
||||
<div
|
||||
className="fixed left-1/2 top-1/2 z-50 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-4 shadow-xl"
|
||||
role="dialog"
|
||||
aria-label={title}
|
||||
>
|
||||
<h3 className="mb-2 font-mono text-sm font-medium text-zinc-800 dark:text-zinc-100">{title}</h3>
|
||||
<p className="mb-4 text-sm text-zinc-600 dark:text-zinc-400">{message}</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded border border-zinc-300 dark:border-zinc-600 px-3 py-1.5 font-mono text-xs text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
className={`rounded px-3 py-1.5 font-mono text-xs ${
|
||||
variant === "danger"
|
||||
? "border border-red-500/50 bg-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-500/30"
|
||||
: "border border-cyan-500/50 bg-cyan-500/20 text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/30"
|
||||
}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
190
src/components/DimensionCard.tsx
Normal file
190
src/components/DimensionCard.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
interface Dimension {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
rubric: string;
|
||||
suggestedQuestions?: string | null;
|
||||
}
|
||||
|
||||
interface DimensionScore {
|
||||
score: number | null;
|
||||
justification: string | null;
|
||||
examplesObserved: string | null;
|
||||
confidence: string | null;
|
||||
candidateNotes: string | null;
|
||||
}
|
||||
|
||||
interface DimensionCardProps {
|
||||
dimension: Dimension;
|
||||
score: DimensionScore | null;
|
||||
index: number;
|
||||
onScoreChange: (dimensionId: string, data: Partial<DimensionScore>) => void;
|
||||
}
|
||||
|
||||
function parseRubric(rubric: string): string[] {
|
||||
if (rubric === "1-5" || !rubric) return ["1", "2", "3", "4", "5"];
|
||||
const labels: string[] = [];
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const m = rubric.match(new RegExp(`${i}:([^;]+)`));
|
||||
labels.push(m ? m[1].trim() : String(i));
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
function parseQuestions(s: string | null | undefined): string[] {
|
||||
if (!s) return [];
|
||||
try {
|
||||
const arr = JSON.parse(s) as unknown;
|
||||
return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-700/80 px-2.5 py-1.5 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30";
|
||||
|
||||
export function DimensionCard({ dimension, score, index, onScoreChange }: DimensionCardProps) {
|
||||
const [notes, setNotes] = useState(score?.candidateNotes ?? "");
|
||||
const hasQuestions = parseQuestions(dimension.suggestedQuestions).length > 0;
|
||||
const [expanded, setExpanded] = useState(hasQuestions);
|
||||
const currentScore = score?.score ?? null;
|
||||
const rubricLabels = parseRubric(dimension.rubric);
|
||||
const questions = parseQuestions(dimension.suggestedQuestions);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-sm dark:shadow-none overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="flex w-full items-center justify-between gap-4 px-4 py-3 text-left hover:bg-zinc-50 dark:hover:bg-zinc-700/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="font-mono text-xs text-zinc-500 tabular-nums w-5">{index + 1}.</span>
|
||||
<span className="font-medium text-zinc-800 dark:text-zinc-100 truncate">{dimension.title}</span>
|
||||
{currentScore != null && (
|
||||
<span
|
||||
className={`shrink-0 font-mono text-xs px-1.5 py-0.5 rounded ${
|
||||
currentScore <= 2 ? "bg-amber-500/20 text-amber-600 dark:text-amber-400" :
|
||||
currentScore >= 4 ? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400" :
|
||||
"bg-zinc-300 dark:bg-zinc-700 text-zinc-700 dark:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{currentScore}/5
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-zinc-500 text-sm">{expanded ? "−" : "+"}</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-zinc-200 dark:border-zinc-600 px-4 py-3 space-y-3">
|
||||
{/* Questions suggérées - en premier */}
|
||||
{questions.length > 0 && (
|
||||
<div className="rounded bg-zinc-50 dark:bg-zinc-700/80 p-2.5">
|
||||
<p className="mb-2 text-xs font-medium text-zinc-500">Questions suggérées</p>
|
||||
<ol className="list-decimal list-inside space-y-1 text-sm text-zinc-700 dark:text-zinc-200">
|
||||
{questions.map((q, i) => (
|
||||
<li key={i}>{q}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rubric */}
|
||||
<div className="rounded bg-zinc-50 dark:bg-zinc-700/80 p-2.5 font-mono text-xs">
|
||||
{rubricLabels.map((r, i) => (
|
||||
<div key={i} className="text-zinc-600 dark:text-zinc-300">
|
||||
<span className="text-cyan-600 dark:text-cyan-500/80">{i + 1}</span> {r}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick score */}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-zinc-500 mr-2">Score:</span>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => onScoreChange(dimension.id, { score: n })}
|
||||
className={`w-8 h-8 rounded font-mono text-sm font-medium transition-colors ${
|
||||
currentScore === n
|
||||
? "bg-cyan-500 text-white"
|
||||
: "bg-zinc-200 dark:bg-zinc-600 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-300 dark:hover:bg-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-100"
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
{currentScore != null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onScoreChange(dimension.id, { score: null })}
|
||||
className="ml-1 text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
|
||||
>
|
||||
reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes candidat */}
|
||||
<div>
|
||||
<label className="text-xs text-zinc-500 mb-1 block">Notes candidat</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => {
|
||||
setNotes(e.target.value);
|
||||
onScoreChange(dimension.id, { candidateNotes: e.target.value });
|
||||
}}
|
||||
rows={2}
|
||||
className={inputClass}
|
||||
placeholder="Réponses du candidat..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Justification, exemples, confiance */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-zinc-500">Justification</label>
|
||||
<input
|
||||
type="text"
|
||||
value={score?.justification ?? ""}
|
||||
onChange={(e) => onScoreChange(dimension.id, { justification: e.target.value || null })}
|
||||
className={inputClass}
|
||||
placeholder="Courte..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-zinc-500">Exemples</label>
|
||||
<input
|
||||
type="text"
|
||||
value={score?.examplesObserved ?? ""}
|
||||
onChange={(e) => onScoreChange(dimension.id, { examplesObserved: e.target.value || null })}
|
||||
className={inputClass}
|
||||
placeholder="Concrets..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-zinc-500">Confiance</label>
|
||||
<select
|
||||
value={score?.confidence ?? ""}
|
||||
onChange={(e) => onScoreChange(dimension.id, { confidence: e.target.value || null })}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="low">Faible</option>
|
||||
<option value="med">Moyenne</option>
|
||||
<option value="high">Haute</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
src/components/ExportModal.tsx
Normal file
51
src/components/ExportModal.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
interface ExportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
evaluationId: string;
|
||||
}
|
||||
|
||||
export function ExportModal({ isOpen, onClose, evaluationId }: ExportModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const base = typeof window !== "undefined" ? window.location.origin : "";
|
||||
const csvUrl = `${base}/api/export/csv?id=${evaluationId}`;
|
||||
const pdfUrl = `${base}/api/export/pdf?id=${evaluationId}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40 bg-black/60" onClick={onClose} aria-hidden="true" />
|
||||
<div
|
||||
className="fixed left-1/2 top-1/2 z-50 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-4 shadow-xl"
|
||||
role="dialog"
|
||||
aria-label="Export"
|
||||
>
|
||||
<h3 className="mb-4 font-mono text-sm font-medium text-zinc-800 dark:text-zinc-200">Export</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<a
|
||||
href={csvUrl}
|
||||
download
|
||||
className="rounded border border-zinc-300 dark:border-zinc-600 bg-zinc-100 dark:bg-zinc-700 px-4 py-2 text-center font-mono text-xs text-zinc-700 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700"
|
||||
>
|
||||
csv
|
||||
</a>
|
||||
<a
|
||||
href={pdfUrl}
|
||||
download
|
||||
className="rounded border border-zinc-300 dark:border-zinc-600 bg-zinc-100 dark:bg-zinc-700 px-4 py-2 text-center font-mono text-xs text-zinc-700 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700"
|
||||
>
|
||||
pdf
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="mt-4 w-full rounded border border-zinc-300 dark:border-zinc-700 py-2 font-mono text-xs text-zinc-600 dark:text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
fermer
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
src/components/Header.tsx
Normal file
28
src/components/Header.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900 shadow-sm dark:shadow-none backdrop-blur-sm">
|
||||
<div className="mx-auto flex h-12 max-w-6xl items-center justify-between px-4">
|
||||
<Link href="/" className="font-mono text-sm font-medium text-zinc-900 dark:text-zinc-50 tracking-tight">
|
||||
iag-eval
|
||||
</Link>
|
||||
<nav className="flex items-center gap-6 font-mono text-xs">
|
||||
<Link href="/" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors">
|
||||
/dashboard
|
||||
</Link>
|
||||
<Link href="/evaluations/new" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors">
|
||||
/new
|
||||
</Link>
|
||||
<Link href="/admin" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors">
|
||||
/admin
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
76
src/components/RadarChart.tsx
Normal file
76
src/components/RadarChart.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Radar,
|
||||
RadarChart as RechartsRadar,
|
||||
PolarGrid,
|
||||
PolarAngleAxis,
|
||||
PolarRadiusAxis,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
|
||||
interface DataPoint {
|
||||
dimension: string;
|
||||
score: number;
|
||||
fullMark: number;
|
||||
}
|
||||
|
||||
interface RadarChartProps {
|
||||
data: DataPoint[];
|
||||
}
|
||||
|
||||
const LIGHT = {
|
||||
grid: "#d4d4d8",
|
||||
tick: "#71717a",
|
||||
axis: "#a1a1aa",
|
||||
tooltipBg: "#fafafa",
|
||||
tooltipBorder: "#e4e4e7",
|
||||
tooltipText: "#18181b",
|
||||
};
|
||||
const DARK = {
|
||||
grid: "#71717a",
|
||||
tick: "#a1a1aa",
|
||||
axis: "#d4d4d8",
|
||||
tooltipBg: "#27272a",
|
||||
tooltipBorder: "#52525b",
|
||||
tooltipText: "#fafafa",
|
||||
};
|
||||
|
||||
export function RadarChart({ data }: RadarChartProps) {
|
||||
const { theme } = useTheme();
|
||||
const c = theme === "dark" ? DARK : LIGHT;
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsRadar data={data}>
|
||||
<PolarGrid stroke={c.grid} />
|
||||
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 9, fill: c.axis }} />
|
||||
<PolarRadiusAxis angle={30} domain={[0, 5]} tick={{ fill: c.tick }} />
|
||||
<Radar
|
||||
name="Score"
|
||||
dataKey="score"
|
||||
stroke="#0891b2"
|
||||
fill="#0891b2"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: c.tooltipBg,
|
||||
border: `1px solid ${c.tooltipBorder}`,
|
||||
borderRadius: "4px",
|
||||
color: c.tooltipText,
|
||||
fontSize: "12px",
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: "11px" }} />
|
||||
</RechartsRadar>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/components/ThemeProvider.tsx
Normal file
40
src/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
|
||||
const ThemeContext = createContext<{ theme: Theme; setTheme: (t: Theme) => void } | null>(null);
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>("dark");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem("theme") as Theme | null;
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
const initial = stored ?? (prefersDark ? "dark" : "light");
|
||||
setThemeState(initial);
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
localStorage.setItem("theme", theme);
|
||||
}, [theme, mounted]);
|
||||
|
||||
const setTheme = (t: Theme) => setThemeState(t);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
18
src/components/ThemeToggle.tsx
Normal file
18
src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className="font-mono text-xs text-zinc-600 dark:text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-300 transition-colors"
|
||||
title={theme === "dark" ? "Passer au thème clair" : "Passer au thème sombre"}
|
||||
>
|
||||
{theme === "dark" ? "☀" : "☽"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user