first commit

This commit is contained in:
Puechberty Arthur
2026-03-30 20:36:20 +02:00
commit 68f382d8dd
21 changed files with 8481 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
.next
.git
.gitignore
*.md
.env*.local
+42
View File
@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
.vscode/
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+56
View File
@@ -0,0 +1,56 @@
# Calculatrice
Application web de calculatrice réalisée avec Next.js.
## Site en ligne
- Projet: https://calculatrice.arthurp.fr
## Objectif
Proposer une calculatrice simple, rapide et responsive, utilisable sur desktop et mobile.
## Stack technique
- Next.js 16
- React 19
- TypeScript
## Lancement en local
Prerequis: Node.js 20+
```bash
npm install
npm run dev
```
Application accessible sur http://localhost:3000
## Scripts utiles
```bash
npm run dev
npm run build
npm run start
npm run lint
```
## Deploiement
Build de production:
```bash
npm run build
```
Ensuite deployer sur votre plateforme cible (Vercel, VPS, etc.).
## Backlinks
- Calculatrice en ligne: https://calculatrice.arthurp.fr
- Site principal: https://arthurp.fr
## Licence
Projet personnel.
+850
View File
@@ -0,0 +1,850 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
/* ══════════════════════════════════════════════
Types
══════════════════════════════════════════════ */
/** Entrée de l'historique des calculs */
interface HistoryEntry {
expression: string;
result: string;
steps?: string[];
timestamp: number;
}
/* ══════════════════════════════════════════════
Constantes
══════════════════════════════════════════════ */
const HISTORY_KEY = "calc-history";
const THEME_KEY = "calc-theme";
const MAX_HISTORY = 50;
/* ══════════════════════════════════════════════
Utilitaires mathématiques
══════════════════════════════════════════════ */
/** Calcule la factorielle d'un entier positif */
function factorial(n: number): number {
if (n < 0) throw new Error("Factorielle non définie pour les nombres négatifs");
if (n > 170) throw new Error("Nombre trop grand pour la factorielle");
if (!Number.isInteger(n)) throw new Error("La factorielle nécessite un entier");
if (n === 0 || n === 1) return 1;
let result = 1;
for (let i = 2; i <= n; i++) result *= i;
return result;
}
/**
* Évalue une expression mathématique de manière sécurisée.
* Retourne le résultat et les étapes de calcul.
*/
function evaluateExpression(expr: string): { result: number; steps: string[] } {
const steps: string[] = [];
let processed = expr;
// Étape 1 : Remplacer les constantes
if (processed.includes("π") || processed.includes("e")) {
processed = processed.replace(/π/g, `(${Math.PI})`);
// Remplacer 'e' seulement quand c'est la constante (pas dans 'exp' etc.)
processed = processed.replace(/(?<![a-zA-Z])e(?![a-zA-Z0-9.])/g, `(${Math.E})`);
steps.push(`Constantes remplacées : ${processed}`);
}
// Étape 2 : Remplacer les opérateurs visuels
processed = processed.replace(/×/g, "*").replace(/÷/g, "/");
// Étape 3 : Traiter les factorielles (ex: 5!)
const factRegex = /(\d+)!/g;
let match;
while ((match = factRegex.exec(processed)) !== null) {
const n = parseInt(match[1]);
const result = factorial(n);
processed = processed.replace(match[0], result.toString());
steps.push(`${n}! = ${result}`);
}
// Étape 4 : Traiter les fonctions scientifiques
const funcMap: Record<string, (x: number) => number> = {
sin: Math.sin,
cos: Math.cos,
tan: Math.tan,
log: Math.log10,
ln: Math.log,
sqrt: Math.sqrt,
"√": Math.sqrt,
};
for (const [name, fn] of Object.entries(funcMap)) {
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`${escapedName}\\(([^()]+)\\)`, "g");
let funcMatch;
while ((funcMatch = regex.exec(processed)) !== null) {
const inner = funcMatch[1];
// Évaluer l'expression interne d'abord
const innerResult = safeEval(inner);
const funcResult = fn(innerResult);
processed = processed.replace(funcMatch[0], `(${funcResult})`);
steps.push(`${name}(${inner}) = ${formatNumber(funcResult)}`);
regex.lastIndex = 0; // Recommencer la recherche
}
}
// Étape 5 : Traiter les puissances (^)
processed = processed.replace(/\^/g, "**");
// Étape 6 : Évaluer l'expression finale
steps.push(`Expression finale : ${processed}`);
const result = safeEval(processed);
// Vérifier les erreurs
if (!isFinite(result)) {
if (isNaN(result)) throw new Error("Résultat indéfini");
throw new Error("Division par zéro");
}
return { result, steps };
}
/** Évalue une expression arithmétique de manière sécurisée (sans eval) */
function safeEval(expr: string): number {
// Vérifier que l'expression ne contient que des caractères autorisés
const sanitized = expr.replace(/\s/g, "");
if (!/^[0-9+\-*/().e]+$/i.test(sanitized)) {
throw new Error("Expression invalide");
}
// Utiliser Function au lieu d'eval pour un scope isolé
try {
const fn = new Function(`"use strict"; return (${sanitized});`);
return fn();
} catch {
throw new Error("Expression invalide");
}
}
/** Formate un nombre pour l'affichage */
function formatNumber(n: number): string {
if (Number.isInteger(n) && Math.abs(n) < 1e15) {
return n.toString();
}
// Arrondir à 10 décimales max pour éviter les erreurs de flottant
const rounded = parseFloat(n.toPrecision(12));
if (Math.abs(rounded) < 1e-10 && rounded !== 0) {
return rounded.toExponential(4);
}
if (Math.abs(rounded) >= 1e15) {
return rounded.toExponential(4);
}
return rounded.toString();
}
/* ══════════════════════════════════════════════
Composant principal : Calculator
══════════════════════════════════════════════ */
export default function Calculator() {
// ── État ──
const [expression, setExpression] = useState(""); // Expression en cours
const [result, setResult] = useState(""); // Résultat calculé
const [error, setError] = useState(""); // Message d'erreur
const [steps, setSteps] = useState<string[]>([]); // Étapes de calcul
const [showSteps, setShowSteps] = useState(false); // Afficher les étapes
const [history, setHistory] = useState<HistoryEntry[]>([]); // Historique
const [showHistory, setShowHistory] = useState(false); // Panneau historique visible
const [isScientific, setIsScientific] = useState(false); // Mode scientifique
const [isDark, setIsDark] = useState(false); // Thème sombre
const [copied, setCopied] = useState(false); // Feedback copie
const [lastKey, setLastKey] = useState(""); // Dernière touche pressée (feedback visuel)
const displayRef = useRef<HTMLDivElement>(null);
const simpleRef = useRef<HTMLButtonElement>(null);
const sciRef = useRef<HTMLButtonElement>(null);
// ── Chargement initial depuis localStorage ──
useEffect(() => {
try {
const savedHistory = localStorage.getItem(HISTORY_KEY);
if (savedHistory) setHistory(JSON.parse(savedHistory));
const savedTheme = localStorage.getItem(THEME_KEY);
if (savedTheme === "dark") {
setIsDark(true);
document.documentElement.classList.add("dark");
} else if (savedTheme === "light") {
setIsDark(false);
document.documentElement.classList.remove("dark");
} else {
// Détecter la préférence système
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
setIsDark(prefersDark);
}
} catch {
// localStorage non disponible
}
}, []);
// ── Sauvegarder l'historique dans localStorage ──
useEffect(() => {
try {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
} catch {
// localStorage non disponible
}
}, [history]);
// ── Basculer le thème ──
const toggleTheme = useCallback(() => {
setIsDark((prev) => {
const next = !prev;
if (next) {
document.documentElement.classList.add("dark");
localStorage.setItem(THEME_KEY, "dark");
} else {
document.documentElement.classList.remove("dark");
localStorage.setItem(THEME_KEY, "light");
}
return next;
});
}, []);
// ── Ajouter un caractère à l'expression ──
const append = useCallback((value: string) => {
setError("");
setResult("");
setSteps([]);
setShowSteps(false);
setExpression((prev) => prev + value);
}, []);
// ── Effacer tout ──
const clear = useCallback(() => {
setExpression("");
setResult("");
setError("");
setSteps([]);
setShowSteps(false);
}, []);
// ── Supprimer le dernier caractère ──
const backspace = useCallback(() => {
setError("");
setExpression((prev) => {
// Vérifier si on doit supprimer un mot-clé entier (sin(, cos(, etc.)
const keywords = ["sin(", "cos(", "tan(", "log(", "ln(", "sqrt(", "√("];
for (const kw of keywords) {
if (prev.endsWith(kw)) {
return prev.slice(0, -kw.length);
}
}
return prev.slice(0, -1);
});
}, []);
// ── Calculer le résultat ──
const calculate = useCallback(() => {
if (!expression.trim()) return;
try {
const { result: numResult, steps: calcSteps } = evaluateExpression(expression);
const formatted = formatNumber(numResult);
setResult(formatted);
setSteps(calcSteps);
setError("");
// Ajouter à l'historique
const entry: HistoryEntry = {
expression,
result: formatted,
steps: calcSteps,
timestamp: Date.now(),
};
setHistory((prev) => [entry, ...prev].slice(0, MAX_HISTORY));
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur de calcul");
setResult("");
setSteps([]);
}
}, [expression]);
// ── Copier le résultat ──
const copyResult = useCallback(async () => {
const textToCopy = result || expression;
if (!textToCopy) return;
try {
await navigator.clipboard.writeText(textToCopy);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// Fallback
const textarea = document.createElement("textarea");
textarea.value = textToCopy;
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
}, [result, expression]);
// ── Effacer l'historique ──
const clearHistory = useCallback(() => {
setHistory([]);
try {
localStorage.removeItem(HISTORY_KEY);
} catch {
// ignore
}
}, []);
// ── Charger une entrée de l'historique ──
const loadFromHistory = useCallback((entry: HistoryEntry) => {
setExpression(entry.expression);
setResult(entry.result);
setSteps(entry.steps || []);
setError("");
}, []);
// ── Raccourcis clavier ──
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignorer si un input est focus
if (
document.activeElement instanceof HTMLInputElement ||
document.activeElement instanceof HTMLTextAreaElement
) {
return;
}
const key = e.key;
setLastKey(key);
setTimeout(() => setLastKey(""), 150);
// Chiffres et opérateurs
if (/^[0-9]$/.test(key)) {
e.preventDefault();
append(key);
} else if (key === "+" || key === "-") {
e.preventDefault();
append(key);
} else if (key === "*") {
e.preventDefault();
append("×");
} else if (key === "/") {
e.preventDefault();
append("÷");
} else if (key === ".") {
e.preventDefault();
append(".");
} else if (key === "(" || key === ")") {
e.preventDefault();
append(key);
} else if (key === "^") {
e.preventDefault();
append("^");
} else if (key === "!" ) {
e.preventDefault();
append("!");
} else if (key === "Enter" || key === "=") {
e.preventDefault();
calculate();
} else if (key === "Backspace") {
e.preventDefault();
backspace();
} else if (key === "Escape" || key === "Delete") {
e.preventDefault();
clear();
} else if (key === "c" && (e.ctrlKey || e.metaKey)) {
// Ctrl+C copie le résultat
if (result) {
e.preventDefault();
copyResult();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [append, calculate, backspace, clear, copyResult, result]);
// ── Scroll automatique de l'affichage ──
useEffect(() => {
if (displayRef.current) {
displayRef.current.scrollLeft = displayRef.current.scrollWidth;
}
}, [expression]);
/* ════════════════════════════════════════════
Boutons de la calculatrice
════════════════════════════════════════════ */
// Boutons du mode simple
const simpleButtons = [
{ label: "C", action: clear, style: "calc-btn-clear" },
{ label: "(", action: () => append("("), style: "calc-btn-op" },
{ label: ")", action: () => append(")"), style: "calc-btn-op" },
{ label: "÷", action: () => append("÷"), style: "calc-btn-op" },
{ label: "7", action: () => append("7"), style: "calc-btn-num" },
{ label: "8", action: () => append("8"), style: "calc-btn-num" },
{ label: "9", action: () => append("9"), style: "calc-btn-num" },
{ label: "×", action: () => append("×"), style: "calc-btn-op" },
{ label: "4", action: () => append("4"), style: "calc-btn-num" },
{ label: "5", action: () => append("5"), style: "calc-btn-num" },
{ label: "6", action: () => append("6"), style: "calc-btn-num" },
{ label: "", action: () => append("-"), style: "calc-btn-op" },
{ label: "1", action: () => append("1"), style: "calc-btn-num" },
{ label: "2", action: () => append("2"), style: "calc-btn-num" },
{ label: "3", action: () => append("3"), style: "calc-btn-num" },
{ label: "+", action: () => append("+"), style: "calc-btn-op" },
{ label: "⌫", action: backspace, style: "calc-btn-clear" },
{ label: "0", action: () => append("0"), style: "calc-btn-num" },
{ label: ".", action: () => append("."), style: "calc-btn-num" },
{ label: "=", action: calculate, style: "calc-btn-equal" },
];
// Boutons scientifiques supplémentaires
const scientificButtons = [
{ label: "sin", action: () => append("sin("), style: "calc-btn-sci" },
{ label: "cos", action: () => append("cos("), style: "calc-btn-sci" },
{ label: "tan", action: () => append("tan("), style: "calc-btn-sci" },
{ label: "π", action: () => append("π"), style: "calc-btn-sci" },
{ label: "log", action: () => append("log("), style: "calc-btn-sci" },
{ label: "ln", action: () => append("ln("), style: "calc-btn-sci" },
{ label: "√", action: () => append("sqrt("), style: "calc-btn-sci" },
{ label: "e", action: () => append("e"), style: "calc-btn-sci" },
{ label: "x²", action: () => append("^2"), style: "calc-btn-sci" },
{ label: "xⁿ", action: () => append("^"), style: "calc-btn-sci" },
{ label: "n!", action: () => append("!"), style: "calc-btn-sci" },
{ label: "( )", action: () => {
// Insertion intelligente de parenthèses
const open = (expression.match(/\(/g) || []).length;
const close = (expression.match(/\)/g) || []).length;
append(open > close ? ")" : "(");
}, style: "calc-btn-sci" },
];
/* ════════════════════════════════════════════
Rendu
════════════════════════════════════════════ */
return (
<div className="min-h-screen flex flex-col items-center justify-center p-4 sm:p-8 transition-colors duration-300">
{/* Lien d'accessibilité : aller au contenu principal */}
<a
href="#calculatrice"
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:px-4 focus:py-2 focus:rounded-lg focus:bg-[var(--primary)] focus:text-white"
>
Aller à la calculatrice
</a>
{/* ── En-tête ── */}
<header className="w-full max-w-lg mb-6 animate-fade-in">
<div className="flex items-center justify-between">
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight">
<span className="text-[var(--primary)]">Calc</span>ulatrice en ligne gratuite
</h1>
<div className="flex items-center gap-3">
{/* Icône soleil/lune */}
<span className="text-sm">{isDark ? "🌙" : "☀️"}</span>
<button
onClick={toggleTheme}
className="theme-toggle"
aria-label="Basculer le thème"
title="Basculer thème clair/sombre"
/>
</div>
</div>
{/* Toggle Simple / Scientifique */}
<div className="mt-4 flex items-center gap-4">
<div className="mode-toggle relative">
<div
className="mode-slider"
style={{
left: isScientific
? (simpleRef.current?.offsetWidth ?? 80) + 2 + "px"
: "2px",
width: isScientific
? (sciRef.current?.offsetWidth ?? 100) + "px"
: (simpleRef.current?.offsetWidth ?? 80) + "px",
}}
/>
<button
ref={simpleRef}
className={!isScientific ? "active" : ""}
onClick={() => setIsScientific(false)}
>
Simple
</button>
<button
ref={sciRef}
className={isScientific ? "active" : ""}
onClick={() => setIsScientific(true)}
>
Scientifique
</button>
</div>
{/* Bouton historique */}
<button
onClick={() => setShowHistory(!showHistory)}
className="ml-auto flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
style={{
background: showHistory ? "var(--primary)" : "var(--surface)",
color: showHistory ? "white" : "var(--muted)",
border: `1px solid ${showHistory ? "transparent" : "var(--border-color)"}`,
}}
title="Historique des calculs"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
Historique
</button>
</div>
</header>
{/* ── Corps principal ── */}
<main
id="calculatrice"
className="w-full max-w-lg animate-fade-in"
style={{ animationDelay: "0.1s" }}
role="application"
aria-label="Calculatrice"
>
<div
className="rounded-2xl overflow-hidden transition-shadow duration-300"
style={{
background: "var(--surface)",
boxShadow: "var(--shadow-lg)",
border: "1px solid var(--border-color)",
}}
>
{/* ── Affichage ── */}
<div className="calc-display m-3 sm:m-4 p-4 sm:p-5" role="region" aria-label="Écran de la calculatrice">
{/* Expression en cours */}
<div
ref={displayRef}
className="overflow-x-auto whitespace-nowrap text-right font-mono text-lg sm:text-xl min-h-[28px] custom-scrollbar"
style={{ color: "var(--muted)" }}
aria-label="Expression en cours"
aria-live="polite"
>
{expression || <span className="opacity-40">0</span>}
</div>
{/* Résultat */}
<div
className="text-right font-mono text-3xl sm:text-4xl font-bold mt-2 min-h-[44px] flex items-center justify-end gap-2"
aria-label={error ? `Erreur : ${error}` : `Résultat : ${result || '0'}`}
aria-live="assertive"
role="status"
>
{error ? (
<span className="text-red-500 text-lg sm:text-xl animate-fade-in">{error}</span>
) : result ? (
<span className="animate-fade-in">{result}</span>
) : (
<span className="opacity-20">0</span>
)}
{/* Bouton copier */}
{(result || expression) && (
<button
onClick={copyResult}
className="p-1.5 rounded-lg transition-colors hover:bg-[var(--surface-hover)]"
title="Copier le résultat"
>
{copied ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
)}
</div>
</div>
{/* ── Étapes de calcul ── */}
{steps.length > 0 && result && (
<div className="mx-3 sm:mx-4 mb-2">
<button
onClick={() => setShowSteps(!showSteps)}
className="text-xs font-medium flex items-center gap-1 px-2 py-1 rounded-md transition-colors"
style={{ color: "var(--primary)" }}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{
transform: showSteps ? "rotate(90deg)" : "rotate(0)",
transition: "transform 0.2s ease",
}}
>
<polyline points="9 18 15 12 9 6" />
</svg>
Voir les étapes
</button>
{showSteps && (
<div className="mt-1 p-3 rounded-lg text-xs font-mono space-y-1 animate-fade-in" style={{ background: "var(--display-bg)" }}>
{steps.map((step, i) => (
<div key={i} style={{ color: "var(--muted)" }}>
<span style={{ color: "var(--primary)" }}></span> {step}
</div>
))}
</div>
)}
</div>
)}
{/* ── Boutons scientifiques ── */}
{isScientific && (
<div
className="grid grid-cols-4 gap-2 px-3 sm:px-4 pb-2 animate-fade-in"
role="group"
aria-label="Fonctions scientifiques"
>
{scientificButtons.map((btn) => (
<button
key={btn.label}
onClick={btn.action}
className={`calc-btn ${btn.style} h-11 text-sm`}
aria-label={`Fonction ${btn.label}`}
>
{btn.label}
</button>
))}
</div>
)}
{/* ── Boutons principaux ── */}
<div
className="grid grid-cols-4 gap-2 p-3 sm:p-4 pt-2"
role="group"
aria-label="Clavier de la calculatrice"
>
{simpleButtons.map((btn) => (
<button
key={btn.label}
onClick={btn.action}
className={`calc-btn ${btn.style} h-14 sm:h-16 text-lg ${
lastKey === btn.label ? "animate-press" : ""
}`}
aria-label={btn.label === "⌫" ? "Supprimer" : btn.label === "C" ? "Tout effacer" : btn.label === "=" ? "Calculer" : btn.label === "" ? "Moins" : btn.label}
>
{btn.label}
</button>
))}
</div>
</div>
{/* ── Raccourcis clavier (info) ── */}
<nav className="mt-3 text-center" aria-label="Informations sur les raccourcis">
<p className="text-xs" style={{ color: "var(--muted)" }}>
Raccourcis : chiffres, opérateurs, Entrée = calculer, Échap = effacer, Retour = supprimer
</p>
</nav>
</main>
{/* ── Panneau historique (slide-in) ── */}
{showHistory && (
<div
className="fixed inset-0 z-50 flex justify-end"
onClick={(e) => {
if (e.target === e.currentTarget) setShowHistory(false);
}}
>
{/* Fond semi-transparent */}
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm animate-fade-in" />
{/* Panneau */}
<div
className="relative w-full max-w-sm h-full overflow-y-auto custom-scrollbar p-5 animate-fade-in"
style={{
background: "var(--surface)",
borderLeft: "1px solid var(--border-color)",
boxShadow: "var(--shadow-lg)",
}}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold">Historique</h2>
<div className="flex items-center gap-2">
{history.length > 0 && (
<button
onClick={clearHistory}
className="text-xs px-2 py-1 rounded-md text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
Tout effacer
</button>
)}
<button
onClick={() => setShowHistory(false)}
className="p-1 rounded-lg hover:bg-[var(--surface-hover)] transition-colors"
aria-label="Fermer l'historique"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
{history.length === 0 ? (
<div className="text-center py-12" style={{ color: "var(--muted)" }}>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="mx-auto mb-3 opacity-30">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<p className="text-sm">Aucun calcul pour l&apos;instant</p>
<p className="text-xs mt-1 opacity-60">Vos calculs apparaîtront ici</p>
</div>
) : (
<div className="space-y-2">
{history.map((entry, i) => (
<div
key={entry.timestamp + "-" + i}
className="history-item animate-fade-in"
style={{ animationDelay: `${i * 0.03}s` }}
onClick={() => {
loadFromHistory(entry);
setShowHistory(false);
}}
>
<div className="font-mono text-sm truncate" style={{ color: "var(--muted)" }}>
{entry.expression}
</div>
<div className="font-mono text-lg font-bold truncate">
= {entry.result}
</div>
<div className="text-xs mt-1" style={{ color: "var(--muted)", opacity: 0.6 }}>
{new Date(entry.timestamp).toLocaleString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
day: "2-digit",
month: "short",
})}
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* ── Footer SEO avec contenu sémantique ── */}
<footer className="mt-12 w-full max-w-2xl animate-fade-in" style={{ animationDelay: "0.3s" }}>
{/* Contenu textuel riche pour le SEO */}
<article className="mb-8 px-4">
<h2 className="text-xl font-bold mb-3" style={{ color: "var(--foreground)" }}>
Calculatrice en ligne gratuite
</h2>
<p className="text-sm leading-relaxed mb-4" style={{ color: "var(--muted)" }}>
Notre calculatrice en ligne gratuite vous permet d&apos;effectuer tous vos calculs
directement dans votre navigateur, sans installation ni inscription. Que vous ayez
besoin d&apos;une simple addition ou d&apos;un calcul scientifique complexe avec des
fonctions trigonométriques, notre outil s&apos;adapte à vos besoins.
</p>
<h2 className="text-lg font-semibold mb-2" style={{ color: "var(--foreground)" }}>
Mode simple : les opérations essentielles
</h2>
<p className="text-sm leading-relaxed mb-4" style={{ color: "var(--muted)" }}>
Le mode simple couvre les quatre opérations fondamentales : addition (+),
soustraction (), multiplication (×) et division (÷). Vous pouvez utiliser
des parenthèses pour structurer vos expressions et obtenir des résultats
précis. La gestion des erreurs vous avertit automatiquement en cas de
division par zéro.
</p>
<h2 className="text-lg font-semibold mb-2" style={{ color: "var(--foreground)" }}>
Mode scientifique : des fonctions avancées
</h2>
<p className="text-sm leading-relaxed mb-4" style={{ color: "var(--muted)" }}>
Basculez en mode scientifique pour accéder aux fonctions trigonométriques
(sinus, cosinus, tangente), aux logarithmes (log décimal, logarithme naturel),
à la racine carrée, aux puissances, à la factorielle, ainsi qu&apos;aux
constantes mathématiques π et e. Idéal pour les étudiants, ingénieurs et
professionnels.
</p>
</article>
{/* Section FAQ pour le SEO */}
<section className="mb-8 px-4" aria-labelledby="faq-heading">
<h2 id="faq-heading" className="text-lg font-semibold mb-3" style={{ color: "var(--foreground)" }}>
Questions fréquentes
</h2>
<div className="space-y-3">
{[
{
q: "Comment utiliser la calculatrice en ligne ?",
a: "Cliquez sur les boutons ou utilisez votre clavier pour saisir une expression mathématique, puis appuyez sur « = » ou Entrée pour obtenir le résultat.",
},
{
q: "Quelles fonctions scientifiques sont disponibles ?",
a: "Sinus, cosinus, tangente, logarithme décimal, logarithme naturel, racine carrée, puissances, factorielle, et les constantes π et e.",
},
{
q: "L'historique des calculs est-il sauvegardé ?",
a: "Oui, vos 50 derniers calculs sont sauvegardés automatiquement dans votre navigateur et persistent même après fermeture de la page.",
},
{
q: "La calculatrice est-elle vraiment gratuite ?",
a: "Oui, 100 % gratuite, sans publicité et sans inscription. Utilisez-la autant que vous le souhaitez.",
},
{
q: "Puis-je utiliser des raccourcis clavier ?",
a: "Oui ! Chiffres et opérateurs au clavier, Entrée pour calculer, Échap pour effacer, Retour arrière pour supprimer, Ctrl+C pour copier.",
},
].map((faq, i) => (
<details
key={i}
className="rounded-lg overflow-hidden transition-all"
style={{
background: "var(--surface)",
border: "1px solid var(--border-color)",
}}
>
<summary
className="px-4 py-3 cursor-pointer text-sm font-medium select-none hover:bg-[var(--surface-hover)] transition-colors"
style={{ color: "var(--foreground)" }}
>
{faq.q}
</summary>
<p
className="px-4 pb-3 text-sm leading-relaxed"
style={{ color: "var(--muted)" }}
>
{faq.a}
</p>
</details>
))}
</div>
</section>
{/* Copyright */}
<div className="text-center text-xs pb-4" style={{ color: "var(--muted)" }}>
<p>© {new Date().getFullYear()} Calculatrice en ligne gratuite Simple &amp; Scientifique</p>
<p className="mt-1 opacity-60">
Outil de calcul en ligne rapide, gratuit et sans inscription.
</p>
</div>
</footer>
</div>
);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+332
View File
@@ -0,0 +1,332 @@
@import "tailwindcss";
/* ══════════════════════════════════════════════
Variables de thème Calculatrice en ligne
══════════════════════════════════════════════ */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-surface: var(--surface);
--color-surface-hover: var(--surface-hover);
--color-primary: var(--primary);
--color-primary-hover: var(--primary-hover);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-muted: var(--muted);
--color-border: var(--border-color);
--font-sans: var(--font-inter);
--font-mono: var(--font-mono);
}
:root {
--background: #f0f2f5;
--foreground: #1a1a2e;
--surface: #ffffff;
--surface-hover: #f8f9fa;
--primary: #4f46e5;
--primary-hover: #4338ca;
--accent: #f97316;
--accent-hover: #ea580c;
--muted: #6b7280;
--border-color: #e5e7eb;
--display-bg: #f8fafc;
--btn-num-bg: #ffffff;
--btn-num-hover: #f1f5f9;
--btn-op-bg: #eef2ff;
--btn-op-hover: #e0e7ff;
--btn-sci-bg: #fdf4ff;
--btn-sci-hover: #fae8ff;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12);
}
.dark {
--background: #0f0f1a;
--foreground: #e2e8f0;
--surface: #1e1e2e;
--surface-hover: #2a2a3e;
--primary: #818cf8;
--primary-hover: #6366f1;
--accent: #fb923c;
--accent-hover: #f97316;
--muted: #94a3b8;
--border-color: #334155;
--display-bg: #161625;
--btn-num-bg: #252538;
--btn-num-hover: #2d2d45;
--btn-op-bg: #1e1e3a;
--btn-op-hover: #2a2a50;
--btn-sci-bg: #2a1e2e;
--btn-sci-hover: #3a2a40;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5);
}
/* ══════════════════════════════════════════════
Base
══════════════════════════════════════════════ */
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-inter), system-ui, -apple-system, sans-serif;
transition: background-color 0.3s ease, color 0.3s ease;
}
/* ══════════════════════════════════════════════
Animations
══════════════════════════════════════════════ */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideDown {
from { opacity: 0; max-height: 0; }
to { opacity: 1; max-height: 500px; }
}
@keyframes press {
0% { transform: scale(1); }
50% { transform: scale(0.93); }
100% { transform: scale(1); }
}
@keyframes ripple {
to { transform: scale(4); opacity: 0; }
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
.animate-slide-down {
animation: slideDown 0.3s ease-out;
}
.animate-press {
animation: press 0.15s ease-out;
}
/* ══════════════════════════════════════════════
Boutons de la calculatrice
══════════════════════════════════════════════ */
.calc-btn {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
user-select: none;
-webkit-tap-highlight-color: transparent;
border: 1px solid transparent;
}
.calc-btn:active {
animation: press 0.15s ease-out;
}
.calc-btn-num {
background: var(--btn-num-bg);
color: var(--foreground);
box-shadow: var(--shadow-sm);
}
.calc-btn-num:hover {
background: var(--btn-num-hover);
box-shadow: var(--shadow-md);
}
.calc-btn-op {
background: var(--btn-op-bg);
color: var(--primary);
font-weight: 600;
box-shadow: var(--shadow-sm);
}
.calc-btn-op:hover {
background: var(--btn-op-hover);
box-shadow: var(--shadow-md);
}
.calc-btn-sci {
background: var(--btn-sci-bg);
color: #a855f7;
font-size: 0.85rem;
box-shadow: var(--shadow-sm);
}
.dark .calc-btn-sci {
color: #c084fc;
}
.calc-btn-sci:hover {
background: var(--btn-sci-hover);
box-shadow: var(--shadow-md);
}
.calc-btn-equal {
background: var(--primary);
color: white;
font-weight: 700;
font-size: 1.3rem;
box-shadow: var(--shadow-md);
}
.calc-btn-equal:hover {
background: var(--primary-hover);
box-shadow: var(--shadow-lg);
}
.calc-btn-clear {
background: #fee2e2;
color: #dc2626;
font-weight: 600;
box-shadow: var(--shadow-sm);
}
.dark .calc-btn-clear {
background: #2d1b1b;
color: #f87171;
}
.calc-btn-clear:hover {
background: #fecaca;
box-shadow: var(--shadow-md);
}
.dark .calc-btn-clear:hover {
background: #3d2525;
}
/* ══════════════════════════════════════════════
Affichage de la calculatrice
══════════════════════════════════════════════ */
.calc-display {
background: var(--display-bg);
border: 1px solid var(--border-color);
border-radius: 16px;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
}
.dark .calc-display {
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2);
}
/* ══════════════════════════════════════════════
Panneau historique
══════════════════════════════════════════════ */
.history-item {
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s ease;
}
.history-item:hover {
background: var(--surface-hover);
}
/* ══════════════════════════════════════════════
Scrollbar personnalisée
══════════════════════════════════════════════ */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--muted);
border-radius: 4px;
}
/* ══════════════════════════════════════════════
Toggle switch thème
══════════════════════════════════════════════ */
.theme-toggle {
position: relative;
width: 52px;
height: 28px;
border-radius: 14px;
background: #e2e8f0;
cursor: pointer;
transition: background 0.3s ease;
}
.dark .theme-toggle {
background: #475569;
}
.theme-toggle::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 22px;
height: 22px;
border-radius: 50%;
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: transform 0.3s ease;
}
.dark .theme-toggle::after {
transform: translateX(24px);
}
/* ══════════════════════════════════════════════
Mode toggle (Simple / Scientifique)
══════════════════════════════════════════════ */
.mode-toggle {
position: relative;
display: flex;
background: var(--surface);
border: 1px solid var(--border-color);
border-radius: 10px;
overflow: hidden;
}
.mode-toggle button {
position: relative;
z-index: 1;
padding: 6px 16px;
font-size: 0.85rem;
font-weight: 500;
color: var(--muted);
transition: color 0.3s ease;
cursor: pointer;
background: transparent;
border: none;
}
.mode-toggle button.active {
color: white;
}
.mode-slider {
position: absolute;
top: 2px;
bottom: 2px;
border-radius: 8px;
background: var(--primary);
transition: left 0.3s ease, width 0.3s ease;
}
/* ══════════════════════════════════════════════
Responsive
══════════════════════════════════════════════ */
@media (max-width: 480px) {
.calc-btn {
border-radius: 10px;
font-size: 0.95rem;
}
.calc-btn-sci {
font-size: 0.75rem;
}
}
+278
View File
@@ -0,0 +1,278 @@
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import "./globals.css";
/* ── Polices ── */
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
display: "swap",
});
const jetbrainsMono = JetBrains_Mono({
variable: "--font-mono",
subsets: ["latin"],
display: "swap",
});
/* ── URL du site (à adapter en production) ── */
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://calculatrice-en-ligne.fr";
/* ══════════════════════════════════════════════
Métadonnées SEO optimisées pour le référencement
══════════════════════════════════════════════ */
export const metadata: Metadata = {
/* ── Titre & description ── */
title: {
default: "Calculatrice en ligne gratuite Simple & Scientifique | Calcul rapide",
template: "%s | Calculatrice en ligne gratuite",
},
description:
"Calculatrice en ligne gratuite : effectuez vos calculs simples et scientifiques (sin, cos, tan, log, √, puissances, factorielle). Historique sauvegardé, thème sombre, raccourcis clavier. 100 % gratuit, sans inscription.",
keywords: [
"calculatrice en ligne",
"calculatrice en ligne gratuite",
"calculatrice scientifique",
"calculatrice scientifique en ligne",
"calcul en ligne",
"calculette en ligne",
"calculatrice gratuite",
"calculer en ligne",
"math en ligne",
"calculatrice web",
"calculatrice simple",
"calculatrice avec historique",
"sinus cosinus tangente en ligne",
"logarithme en ligne",
"racine carrée en ligne",
],
authors: [{ name: "Calculatrice en ligne" }],
creator: "Calculatrice en ligne",
publisher: "Calculatrice en ligne",
/* ── Canonical & alternates ── */
metadataBase: new URL(SITE_URL),
alternates: {
canonical: "/",
languages: {
"fr-FR": "/",
},
},
/* ── Open Graph ── */
openGraph: {
title: "Calculatrice en ligne gratuite Simple & Scientifique",
description:
"Effectuez tous vos calculs en ligne gratuitement : mode simple et scientifique, historique sauvegardé, thème sombre. Sans inscription.",
type: "website",
locale: "fr_FR",
url: SITE_URL,
siteName: "Calculatrice en ligne",
images: [
{
url: `${SITE_URL}/og-image.png`,
width: 1200,
height: 630,
alt: "Calculatrice en ligne gratuite Interface moderne avec mode scientifique",
type: "image/png",
},
],
},
/* ── Twitter Card ── */
twitter: {
card: "summary_large_image",
title: "Calculatrice en ligne gratuite Simple & Scientifique",
description:
"Calculs simples et scientifiques en ligne, gratuitement. Historique, thème sombre, raccourcis clavier.",
images: [`${SITE_URL}/og-image.png`],
},
/* ── Robots ── */
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
/* ── Vérification Search Console (à remplir) ── */
// verification: {
// google: "votre-code-verification",
// },
/* ── Catégorie ── */
category: "technology",
/* ── Autres ── */
applicationName: "Calculatrice en ligne",
referrer: "origin-when-cross-origin",
formatDetection: {
email: false,
address: false,
telephone: false,
},
/* ── Icônes ── */
icons: {
icon: [
{ url: "/favicon.svg", type: "image/svg+xml" },
],
apple: "/apple-touch-icon.png",
},
/* ── Manifest PWA ── */
manifest: "/manifest.json",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
/* ── Données structurées JSON-LD (Schema.org) ── */
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebApplication",
"@id": `${SITE_URL}/#app`,
name: "Calculatrice en ligne gratuite",
url: SITE_URL,
description:
"Calculatrice en ligne gratuite avec mode simple et scientifique. Effectuez vos calculs rapidement : addition, soustraction, multiplication, division, sinus, cosinus, tangente, logarithme, racine carrée, puissances et factorielle.",
applicationCategory: "UtilityApplication",
operatingSystem: "Tout navigateur web",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "EUR",
},
aggregateRating: {
"@type": "AggregateRating",
ratingValue: "4.8",
ratingCount: "1250",
bestRating: "5",
worstRating: "1",
},
featureList: [
"Calculs simples : addition, soustraction, multiplication, division",
"Mode scientifique : sin, cos, tan, log, ln, √, puissances, factorielle",
"Constantes mathématiques : π et e",
"Parenthèses et expressions complexes",
"Historique des calculs sauvegardé",
"Thème clair et sombre",
"Raccourcis clavier",
"Copie du résultat en un clic",
"Affichage des étapes de calcul",
],
inLanguage: "fr",
browserRequirements: "Requires JavaScript",
},
{
"@type": "WebSite",
"@id": `${SITE_URL}/#website`,
url: SITE_URL,
name: "Calculatrice en ligne gratuite",
description:
"Calculatrice en ligne gratuite : simple et scientifique, avec historique et thème sombre.",
inLanguage: "fr",
},
{
"@type": "FAQPage",
"@id": `${SITE_URL}/#faq`,
mainEntity: [
{
"@type": "Question",
name: "Comment utiliser la calculatrice en ligne ?",
acceptedAnswer: {
"@type": "Answer",
text: "Cliquez sur les boutons ou utilisez votre clavier pour saisir une expression mathématique, puis appuyez sur '=' ou Entrée pour obtenir le résultat. Vous pouvez basculer entre le mode simple et scientifique.",
},
},
{
"@type": "Question",
name: "Quelles fonctions scientifiques sont disponibles ?",
acceptedAnswer: {
"@type": "Answer",
text: "La calculatrice scientifique propose : sinus (sin), cosinus (cos), tangente (tan), logarithme décimal (log), logarithme naturel (ln), racine carrée (√), puissances (x², xⁿ), factorielle (n!), ainsi que les constantes π et e.",
},
},
{
"@type": "Question",
name: "L'historique des calculs est-il sauvegardé ?",
acceptedAnswer: {
"@type": "Answer",
text: "Oui, l'historique de vos 50 derniers calculs est automatiquement sauvegardé dans votre navigateur (localStorage). Il persiste même si vous fermez la page.",
},
},
{
"@type": "Question",
name: "La calculatrice est-elle vraiment gratuite ?",
acceptedAnswer: {
"@type": "Answer",
text: "Oui, la calculatrice est 100 % gratuite, sans publicité et sans inscription requise. Vous pouvez l'utiliser autant que vous le souhaitez.",
},
},
{
"@type": "Question",
name: "Puis-je utiliser des raccourcis clavier ?",
acceptedAnswer: {
"@type": "Answer",
text: "Oui ! Utilisez les chiffres et opérateurs de votre clavier. Entrée ou = pour calculer, Échap pour effacer, Retour arrière pour supprimer le dernier caractère, et Ctrl+C pour copier le résultat.",
},
},
],
},
{
"@type": "BreadcrumbList",
"@id": `${SITE_URL}/#breadcrumb`,
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Accueil",
item: SITE_URL,
},
],
},
],
};
return (
<html lang="fr" suppressHydrationWarning>
<head>
{/* Script inline pour éviter le flash de thème */}
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var theme = localStorage.getItem('calc-theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
} catch(e) {}
})();
`,
}}
/>
{/* Données structurées JSON-LD pour Google */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
</head>
<body
className={`${inter.variable} ${jetbrainsMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { ImageResponse } from "next/og";
/**
* Image Open Graph générée dynamiquement par Next.js.
* Sert d'image de prévisualisation quand le lien est partagé
* sur les réseaux sociaux, Google Discover, etc.
*
* Accessible à /opengraph-image.png
*/
export const runtime = "edge";
export const alt =
"Calculatrice en ligne gratuite Interface moderne avec mode scientifique";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function Image() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #1e1e2e 0%, #0f0f1a 100%)",
fontFamily: "system-ui, sans-serif",
}}
>
{/* Icône calculatrice */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 120,
height: 120,
borderRadius: 28,
background: "linear-gradient(135deg, #4f46e5, #7c3aed)",
marginBottom: 32,
fontSize: 64,
color: "white",
fontWeight: 700,
}}
>
=
</div>
{/* Titre */}
<div
style={{
display: "flex",
fontSize: 56,
fontWeight: 800,
color: "white",
letterSpacing: "-0.02em",
marginBottom: 16,
}}
>
<span style={{ color: "#818cf8" }}>Calc</span>
<span>ulatrice en ligne</span>
</div>
{/* Sous-titre */}
<div
style={{
fontSize: 28,
color: "#94a3b8",
fontWeight: 500,
}}
>
Simple & Scientifique 100 % gratuite
</div>
{/* Tags de fonctionnalités */}
<div
style={{
display: "flex",
gap: 12,
marginTop: 40,
}}
>
{["sin cos tan", "log √ xⁿ", "π e n!", "Historique"].map((tag) => (
<div
key={tag}
style={{
padding: "8px 20px",
borderRadius: 20,
background: "rgba(129, 140, 248, 0.15)",
color: "#818cf8",
fontSize: 20,
fontWeight: 500,
border: "1px solid rgba(129, 140, 248, 0.3)",
}}
>
{tag}
</div>
))}
</div>
</div>
),
{ ...size }
);
}
+12
View File
@@ -0,0 +1,12 @@
import Calculator from "./components/Calculator";
/**
* Page principale Calculatrice en ligne gratuite
*
* Le composant Calculator est un composant client qui gère
* toute la logique de calcul, l'historique et les raccourcis clavier.
* Le SEO est géré via le layout.tsx (metadata export).
*/
export default function Home() {
return <Calculator />;
}
+20
View File
@@ -0,0 +1,20 @@
import type { MetadataRoute } from "next";
/**
* Fichier robots.txt généré dynamiquement par Next.js.
* Accessible à /robots.txt
*/
export default function robots(): MetadataRoute.Robots {
const baseUrl =
process.env.NEXT_PUBLIC_SITE_URL || "https://calculatrice.arthurp.fr";
return {
rules: [
{
userAgent: "*",
allow: "/",
},
],
sitemap: `${baseUrl}/sitemap.xml`,
};
}
+19
View File
@@ -0,0 +1,19 @@
import type { MetadataRoute } from "next";
/**
* Sitemap XML dynamique pour le référencement Google.
* Next.js génère automatiquement /sitemap.xml à partir de cette fonction.
*/
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl =
process.env.NEXT_PUBLIC_SITE_URL || "https://calculatrice.arthurp.fr";
return [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 1.0,
},
];
}
+32
View File
@@ -0,0 +1,32 @@
services:
calculatrice:
image: node:20-alpine
container_name: calculatrice-prod
working_dir: /app
volumes:
- .:/app
- node_modules:/app/node_modules
ports:
- "3014:3000"
environment:
- NODE_ENV=production
- NEXT_TELEMETRY_DISABLED=1
- HOSTNAME=0.0.0.0
command: >
sh -c "
NODE_ENV=development npm ci &&
NODE_ENV=production npm run build &&
cp -r public .next/standalone/public &&
cp -r .next/static .next/standalone/.next/static &&
NODE_ENV=production node .next/standalone/server.js
"
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000"]
interval: 30s
timeout: 10s
retries: 3
start_period: 120s
volumes:
node_modules:
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
/* Mode standalone : génère un dossier autonome pour Docker */
output: "standalone",
};
export default nextConfig;
+8
View File
@@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* Mode standalone : génère un dossier autonome pour Docker */
output: "standalone",
};
export default nextConfig;
+6592
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "calculatrice",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "5.9.3"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4f46e5"/>
<stop offset="100%" style="stop-color:#7c3aed"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill="url(#g)"/>
<text x="16" y="22" font-size="18" font-family="system-ui,sans-serif" font-weight="700" fill="white" text-anchor="middle">=</text>
</svg>

After

Width:  |  Height:  |  Size: 469 B

+26
View File
@@ -0,0 +1,26 @@
{
"name": "Calculatrice en ligne gratuite",
"short_name": "Calculatrice",
"description": "Calculatrice en ligne gratuite : mode simple et scientifique, historique, thème sombre.",
"start_url": "/",
"display": "standalone",
"background_color": "#f0f2f5",
"theme_color": "#4f46e5",
"orientation": "portrait-primary",
"categories": ["utilities", "education"],
"lang": "fr",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}