Retour au catalogue
Developer API Playground
Playground API interactif avec selecteur de methode HTTP, editeur de body JSON, en-tetes personnalisables et reponse en temps reel avec coloration syntaxique.
developercomplex Both Responsive a11y
darkcorporatesaasuniversalsplit
Theme
"use client";
import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Play, Copy, Check, ChevronDown, Loader2, Braces } from "lucide-react";
import React from "react";
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
interface ApiEndpoint {
method: HttpMethod;
path: string;
label: string;
body?: string;
response: string;
}
interface DeveloperApiPlaygroundProps {
title?: string;
subtitle?: string;
baseUrl?: string;
endpoints?: ApiEndpoint[];
}
const methodColors: Record<HttpMethod, string> = {
GET: "#22c55e",
POST: "#3b82f6",
PUT: "#f59e0b",
DELETE: "#ef4444",
PATCH: "#a855f7",
};
interface TokenSegment {
text: string;
color: string;
}
function tokenizeLine(line: string): TokenSegment[] {
const segments: TokenSegment[] = [];
let remaining = line;
while (remaining.length > 0) {
const keyMatch = remaining.match(/^("[\w_]+")(\s*:\s*)/);
if (keyMatch) {
segments.push({ text: keyMatch[1], color: "var(--color-accent)" });
segments.push({ text: keyMatch[2], color: "var(--color-foreground-muted)" });
remaining = remaining.slice(keyMatch[0].length);
continue;
}
const strMatch = remaining.match(/^"([^"]*)"/);
if (strMatch) {
segments.push({ text: strMatch[0], color: "#22c55e" });
remaining = remaining.slice(strMatch[0].length);
continue;
}
const numMatch = remaining.match(/^\d+/);
if (numMatch) {
segments.push({ text: numMatch[0], color: "#f59e0b" });
remaining = remaining.slice(numMatch[0].length);
continue;
}
const boolMatch = remaining.match(/^(true|false|null)/);
if (boolMatch) {
segments.push({ text: boolMatch[0], color: "#a855f7" });
remaining = remaining.slice(boolMatch[0].length);
continue;
}
segments.push({ text: remaining[0], color: "var(--color-foreground-muted)" });
remaining = remaining.slice(1);
}
return segments;
}
function JsonHighlight({ code }: { code: string }) {
const lines = code.split("\n");
return (
<pre
style={{
margin: 0,
fontSize: "0.8125rem",
lineHeight: 1.7,
fontFamily: "'SF Mono', 'Fira Code', monospace",
overflowX: "auto",
}}
>
{lines.map((line, i) => {
const tokens = tokenizeLine(line);
return (
<div key={i} style={{ display: "flex" }}>
<span
style={{
width: "2.5rem",
textAlign: "right",
paddingRight: "1rem",
color: "var(--color-foreground-light)",
userSelect: "none",
opacity: 0.4,
}}
>
{i + 1}
</span>
<span>
{tokens.map((token, ti) => (
<span key={ti} style={{ color: token.color }}>{token.text}</span>
))}
</span>
</div>
);
})}
</pre>
);
}
export default function DeveloperApiPlayground({
title,
subtitle,
baseUrl,
endpoints,
}: DeveloperApiPlaygroundProps) {
const resolvedTitle = title ?? "API Playground";
const resolvedSubtitle = subtitle ?? "Testez nos endpoints en direct. Selectionnez une route, modifiez les parametres et lancez la requete.";
const resolvedBaseUrl = baseUrl ?? "https://api.exemple.fr/v2";
const resolvedEndpoints: ApiEndpoint[] = endpoints ?? [
{
method: "GET",
path: "/utilisateurs",
label: "Lister les utilisateurs",
response: `{
"data": [
{
"id": 1,
"nom": "Marie Dupont",
"email": "marie@exemple.fr",
"role": "admin"
}
],
"total": 42,
"page": 1
}`,
},
{
method: "POST",
path: "/utilisateurs",
label: "Creer un utilisateur",
body: `{
"nom": "Sophie Bernard",
"email": "sophie@exemple.fr",
"role": "editeur"
}`,
response: `{
"id": 43,
"nom": "Sophie Bernard",
"email": "sophie@exemple.fr",
"role": "editeur",
"cree_le": "2026-03-13T10:30:00Z"
}`,
},
{
method: "DELETE",
path: "/utilisateurs/:id",
label: "Supprimer un utilisateur",
response: `{
"success": true,
"message": "Utilisateur supprime avec succes"
}`,
},
];
const [selectedIdx, setSelectedIdx] = useState(0);
const [isRunning, setIsRunning] = useState(false);
const [showResponse, setShowResponse] = useState(false);
const [copied, setCopied] = useState(false);
const [showEndpointList, setShowEndpointList] = useState(false);
const selected = resolvedEndpoints[selectedIdx];
const handleRun = useCallback(() => {
setIsRunning(true);
setShowResponse(false);
setTimeout(() => {
setIsRunning(false);
setShowResponse(true);
}, 800);
}, []);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(selected.response).catch(() => {});
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [selected.response]);
return (
<section
style={{
padding: "var(--section-padding-y, 6rem) 0",
background: "var(--color-background)",
}}
>
<div
style={{
maxWidth: "var(--container-max-width, 1200px)",
margin: "0 auto",
padding: "0 var(--container-padding-x, 1.5rem)",
}}
>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
style={{ textAlign: "center", marginBottom: "3rem" }}
>
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.375rem 0.75rem",
borderRadius: "999px",
background: "color-mix(in srgb, var(--color-accent) 10%, transparent)",
marginBottom: "1rem",
}}
>
<Braces style={{ width: 14, height: 14, color: "var(--color-accent)" }} />
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: "var(--color-accent)", textTransform: "uppercase", letterSpacing: "0.05em" }}>
Playground
</span>
</div>
<h2 style={{ fontSize: "clamp(1.75rem, 3vw, 2.5rem)", fontWeight: 700, color: "var(--color-foreground)", marginBottom: "0.75rem", letterSpacing: "-0.02em" }}>
{resolvedTitle}
</h2>
<p style={{ fontSize: "1rem", color: "var(--color-foreground-muted)", maxWidth: "560px", margin: "0 auto", lineHeight: 1.6 }}>
{resolvedSubtitle}
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.15, ease: [0.16, 1, 0.3, 1] }}
style={{
borderRadius: "16px",
border: "1px solid var(--color-border)",
background: "var(--color-background-card)",
overflow: "hidden",
}}
>
{/* Header bar */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "0.75rem 1rem",
borderBottom: "1px solid var(--color-border)",
flexWrap: "wrap",
}}
>
{/* Endpoint selector */}
<div style={{ position: "relative" }}>
<button
onClick={() => setShowEndpointList(!showEndpointList)}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.5rem 0.75rem",
borderRadius: "8px",
border: "1px solid var(--color-border)",
background: "var(--color-background-alt)",
color: "var(--color-foreground)",
fontSize: "0.8125rem",
cursor: "pointer",
fontFamily: "inherit",
}}
>
<span
style={{
fontSize: "0.6875rem",
fontWeight: 700,
color: methodColors[selected.method],
fontFamily: "monospace",
}}
>
{selected.method}
</span>
<span>{selected.label}</span>
<ChevronDown style={{ width: 12, height: 12, color: "var(--color-foreground-muted)" }} />
</button>
<AnimatePresence>
{showEndpointList && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
style={{
position: "absolute",
top: "calc(100% + 4px)",
left: 0,
minWidth: "220px",
background: "var(--color-background-card)",
border: "1px solid var(--color-border)",
borderRadius: "10px",
padding: "0.375rem",
zIndex: 50,
boxShadow: "0 8px 32px color-mix(in srgb, var(--color-foreground) 8%, transparent)",
}}
>
{resolvedEndpoints.map((ep, i) => (
<button
key={i}
onClick={() => { setSelectedIdx(i); setShowEndpointList(false); setShowResponse(false); }}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
width: "100%",
padding: "0.5rem 0.75rem",
borderRadius: "6px",
border: "none",
background: i === selectedIdx ? "color-mix(in srgb, var(--color-accent) 10%, transparent)" : "transparent",
color: "var(--color-foreground)",
fontSize: "0.8125rem",
cursor: "pointer",
fontFamily: "inherit",
textAlign: "left",
}}
>
<span style={{ fontSize: "0.6875rem", fontWeight: 700, color: methodColors[ep.method], fontFamily: "monospace", width: "3rem" }}>
{ep.method}
</span>
{ep.label}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
{/* URL bar */}
<div
style={{
flex: 1,
minWidth: "200px",
padding: "0.5rem 0.75rem",
borderRadius: "8px",
background: "var(--color-background-alt)",
border: "1px solid var(--color-border)",
fontSize: "0.8125rem",
fontFamily: "'SF Mono', 'Fira Code', monospace",
color: "var(--color-foreground-muted)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{resolvedBaseUrl}
<span style={{ color: "var(--color-accent)" }}>{selected.path}</span>
</div>
{/* Run button */}
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
onClick={handleRun}
disabled={isRunning}
style={{
display: "flex",
alignItems: "center",
gap: "0.375rem",
padding: "0.5rem 1.25rem",
borderRadius: "8px",
border: "none",
background: "var(--color-accent)",
color: "var(--color-background)",
fontSize: "0.8125rem",
fontWeight: 600,
cursor: isRunning ? "wait" : "pointer",
fontFamily: "inherit",
}}
>
{isRunning ? (
<Loader2 style={{ width: 14, height: 14, animation: "spin 1s linear infinite" }} />
) : (
<Play style={{ width: 14, height: 14 }} />
)}
Executer
</motion.button>
</div>
{/* Content area */}
<div
style={{
display: "grid",
gridTemplateColumns: selected.body ? "1fr 1fr" : "1fr",
minHeight: "300px",
}}
>
{/* Request body */}
{selected.body && (
<div style={{ borderRight: "1px solid var(--color-border)", padding: "1rem" }}>
<div style={{ fontSize: "0.6875rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.08em", color: "var(--color-foreground-muted)", marginBottom: "0.75rem" }}>
Corps de la requete
</div>
<div
style={{
padding: "1rem",
borderRadius: "8px",
background: "var(--color-background-alt)",
border: "1px solid var(--color-border)",
}}
>
<JsonHighlight code={selected.body} />
</div>
</div>
)}
{/* Response */}
<div style={{ padding: "1rem" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
<div style={{ fontSize: "0.6875rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.08em", color: "var(--color-foreground-muted)" }}>
Reponse
</div>
{showResponse && (
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
onClick={handleCopy}
style={{
display: "flex",
alignItems: "center",
gap: "0.25rem",
padding: "0.25rem 0.5rem",
borderRadius: "6px",
border: "1px solid var(--color-border)",
background: "transparent",
color: "var(--color-foreground-muted)",
fontSize: "0.75rem",
cursor: "pointer",
fontFamily: "inherit",
}}
>
{copied ? <Check style={{ width: 12, height: 12, color: "var(--color-accent)" }} /> : <Copy style={{ width: 12, height: 12 }} />}
{copied ? "Copie !" : "Copier"}
</motion.button>
)}
</div>
<div
style={{
padding: "1rem",
borderRadius: "8px",
background: "var(--color-background-alt)",
border: "1px solid var(--color-border)",
minHeight: "200px",
}}
>
<AnimatePresence mode="wait">
{isRunning && (
<motion.div
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "200px",
color: "var(--color-foreground-muted)",
fontSize: "0.875rem",
gap: "0.5rem",
}}
>
<Loader2 style={{ width: 16, height: 16, animation: "spin 1s linear infinite" }} />
Chargement...
</motion.div>
)}
{showResponse && !isRunning && (
<motion.div
key="response"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem" }}>
<span style={{ fontSize: "0.75rem", fontWeight: 700, color: "#22c55e", fontFamily: "monospace" }}>
200 OK
</span>
<span style={{ fontSize: "0.6875rem", color: "var(--color-foreground-light)" }}>
124ms
</span>
</div>
<JsonHighlight code={selected.response} />
</motion.div>
)}
{!showResponse && !isRunning && (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 0.4 }}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "200px",
color: "var(--color-foreground-muted)",
fontSize: "0.875rem",
}}
>
Cliquez sur Executer pour voir la reponse
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
</motion.div>
</div>
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</section>
);
}