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>
  );
}

Avis

Developer API Playground — React Developer Section — Incubator