Retour au catalogue

Changelog Category Filter

Changelog avec filtres par categorie (feature, fix, improvement) et filtrage anime.

changelogmedium Both Responsive a11y
minimalcorporatesaasuniversalstacked
Theme
"use client";

import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Sparkles, TrendingUp, Bug, Tag } from "lucide-react";
import type { LucideIcon } from "lucide-react";

interface Category {
  id: string;
  label: string;
  count: number;
}

interface Entry {
  title: string;
  description: string;
  date: string;
  category: string;
  version: string;
}

interface ChangelogCategoryFilterProps {
  title?: string;
  subtitle?: string;
  categories?: Category[];
  entries?: Entry[];
}

const EASE = [0.16, 1, 0.3, 1] as const;

const catIcons: Record<string, LucideIcon> = {
  feature: Sparkles,
  improvement: TrendingUp,
  fix: Bug,
};

const catColors: Record<string, string> = {
  feature: "var(--color-accent)",
  improvement: "var(--color-foreground)",
  fix: "var(--color-foreground-muted)",
};

export default function ChangelogCategoryFilter({
  title = "Changelog",
  subtitle = "Filtrez par type de changement",
  categories = [],
  entries = [],
}: ChangelogCategoryFilterProps) {
  const [activeFilter, setActiveFilter] = useState("all");
  const filtered = activeFilter === "all" ? entries : entries.filter((e) => e.category === activeFilter);

  return (
    <section style={{ paddingTop: "var(--section-padding-y)", paddingBottom: "var(--section-padding-y)", background: "var(--color-background)" }}>
      <div style={{ maxWidth: 760, margin: "0 auto", padding: "0 var(--container-padding-x)" }}>
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true }}
          transition={{ duration: 0.6, ease: EASE }}
          style={{ marginBottom: "2rem" }}
        >
          <h2 style={{ fontFamily: "var(--font-sans)", fontSize: "clamp(1.75rem, 3vw, 2.5rem)", fontWeight: 700, color: "var(--color-foreground)", marginBottom: "0.5rem" }}>{title}</h2>
          <p style={{ fontSize: "1rem", color: "var(--color-foreground-muted)" }}>{subtitle}</p>
        </motion.div>

        {/* Filter chips */}
        <motion.div
          initial={{ opacity: 0, y: 10 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true }}
          transition={{ duration: 0.4, delay: 0.05, ease: EASE }}
          style={{ display: "flex", gap: "0.5rem", marginBottom: "2rem", flexWrap: "wrap" }}
        >
          {categories.map((cat) => (
            <button
              key={cat.id}
              onClick={() => setActiveFilter(cat.id)}
              style={{
                display: "flex",
                alignItems: "center",
                gap: "0.35rem",
                padding: "0.4rem 1rem",
                borderRadius: "var(--radius-full)",
                border: "1px solid",
                borderColor: activeFilter === cat.id ? "var(--color-accent)" : "var(--color-border)",
                background: activeFilter === cat.id ? "var(--color-accent)" : "var(--color-background)",
                color: activeFilter === cat.id ? "var(--color-background)" : "var(--color-foreground-muted)",
                fontSize: "0.8125rem",
                fontWeight: 600,
                cursor: "pointer",
                transition: "all 0.2s",
              }}
            >
              {cat.label}
              <span style={{ fontSize: "0.6875rem", opacity: 0.7 }}>({cat.count})</span>
            </button>
          ))}
        </motion.div>

        {/* Entries */}
        <div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
          <AnimatePresence mode="popLayout">
            {filtered.map((entry) => {
              const Icon = catIcons[entry.category] ?? Tag;
              const color = catColors[entry.category] ?? "var(--color-foreground-muted)";
              return (
                <motion.div
                  key={entry.title}
                  layout
                  initial={{ opacity: 0, y: 10 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0, scale: 0.95 }}
                  transition={{ duration: 0.3, ease: EASE }}
                  style={{ display: "flex", gap: "1rem", padding: "1.25rem", borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-background-card)", alignItems: "start" }}
                >
                  <div style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", background: "var(--color-background-alt)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
                    <Icon style={{ width: 15, height: 15, color }} />
                  </div>
                  <div style={{ flex: 1 }}>
                    <div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.25rem", flexWrap: "wrap" }}>
                      <h3 style={{ fontSize: "0.9375rem", fontWeight: 600, color: "var(--color-foreground)" }}>{entry.title}</h3>
                      <span style={{ fontSize: "0.6875rem", fontWeight: 600, padding: "0.1rem 0.5rem", borderRadius: "var(--radius-full)", background: "var(--color-background-alt)", color }}>{entry.version}</span>
                    </div>
                    <p style={{ fontSize: "0.8125rem", color: "var(--color-foreground-muted)", lineHeight: 1.6 }}>{entry.description}</p>
                  </div>
                  <span style={{ fontSize: "0.75rem", color: "var(--color-foreground-muted)", flexShrink: 0, whiteSpace: "nowrap" }}>{entry.date}</span>
                </motion.div>
              );
            })}
          </AnimatePresence>
        </div>
      </div>
    </section>
  );
}

Avis

Changelog Category Filter — React Changelog Section — Incubator