chore: prepara versão 2.5.5

Filtros multi-seleção em lançamentos (condição, forma de pagamento, pessoa,
categoria, conta/cartão), changelog redesenhado como timeline colapsável com
detecção de bump e resumo, e diálogos migrados para as animações utilitárias
do tw-animate-css. Inclui ajustes de label no BulkActionDialog, refinamentos
visuais na landing page e atualização da navbar.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-05-07 13:11:59 +00:00
parent 18893bfe02
commit a6fba5f953
20 changed files with 873 additions and 505 deletions

View File

@@ -1,20 +1,26 @@
import Link from "next/link";
import type { ChangelogVersion } from "@/features/settings/lib/parse-changelog";
import { Badge } from "@/shared/components/ui/badge";
import { Card } from "@/shared/components/ui/card";
"use client";
/** Converte "[texto](url)" em link; texto simples fica como está */
function parseContributorLine(content: string) {
const linkMatch = content.match(/^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/);
if (linkMatch) {
return { label: linkMatch[1], url: linkMatch[2] };
}
return { label: content, url: null };
}
import { RiArrowDownSLine } from "@remixicon/react";
import { format, parseISO } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useEffect, useMemo, useState } from "react";
import type {
BumpType,
ChangelogVersion,
} from "@/features/settings/lib/changelog-types";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Card } from "@/shared/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/shared/components/ui/collapsible";
import { cn } from "@/shared/utils/ui";
const sectionBadgeVariant: Record<
string,
"success" | "info" | "destructive" | "secondary" | "outline"
"success" | "info" | "destructive" | "outline" | "secondary"
> = {
Adicionado: "success",
Alterado: "info",
@@ -22,75 +28,211 @@ const sectionBadgeVariant: Record<
Removido: "destructive",
};
function getSectionVariant(type: string) {
return sectionBadgeVariant[type] ?? "secondary";
const dotByBump: Record<BumpType, string> = {
major: "size-4 bg-primary",
minor: "size-3 bg-primary/80",
patch: "size-2.5 bg-muted-foreground/40",
};
const bumpLabel: Record<BumpType, string> = {
major: "Major",
minor: "Minor",
patch: "Patch",
};
function versionAnchorId(version: string) {
return `v${version.replace(/\./g, "-")}`;
}
function anchorIdToVersion(id: string): string | null {
if (!id.startsWith("v")) return null;
return id.slice(1).replace(/-/g, ".");
}
function groupByMonth(versions: ChangelogVersion[]) {
const groups: { key: string; label: string; items: ChangelogVersion[] }[] =
[];
for (const v of versions) {
const date = parseISO(v.isoDate);
const key = Number.isNaN(date.getTime())
? v.isoDate.slice(0, 7)
: format(date, "yyyy-MM");
const label = Number.isNaN(date.getTime())
? key
: format(date, "MMMM 'de' yyyy", { locale: ptBR });
const last = groups.at(-1);
if (last?.key === key) last.items.push(v);
else groups.push({ key, label, items: [v] });
}
return groups;
}
function VersionDetails({ version }: { version: ChangelogVersion }) {
return (
<Card className="space-y-4 p-4 bg-primary/5 dark:bg-primary/5">
{version.sections.map((section) => (
<div key={section.type}>
<Badge
variant={sectionBadgeVariant[section.type] ?? "secondary"}
className="mb-2"
>
{section.type}
</Badge>
<ul className="space-y-2 text-muted-foreground">
{section.items.map((item) => (
<li key={item} className="flex gap-2">
<span className="text-primary">&bull;</span>
<span className="text-sm">{item}</span>
</li>
))}
</ul>
</div>
))}
</Card>
);
}
type TimelineItemProps = {
version: ChangelogVersion;
open: boolean;
onOpenChange: (open: boolean) => void;
isLatest: boolean;
};
function TimelineItem({
version,
open,
onOpenChange,
isLatest,
}: TimelineItemProps) {
const hasDetails = version.sections.length > 0;
const date = parseISO(version.isoDate);
const validDate = !Number.isNaN(date.getTime());
return (
<div className="flex gap-4" id={versionAnchorId(version.version)}>
<div className="flex flex-col items-center pt-1.5">
<span
className={cn(
"rounded-full ring-4 ring-background shrink-0",
dotByBump[version.bump],
)}
aria-label={`Versão ${bumpLabel[version.bump].toLowerCase()}`}
/>
<span className="w-px flex-1 bg-border mt-2" aria-hidden="true" />
</div>
<div className="flex-1 pb-6 space-y-3 min-w-0">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h3 className="font-semibold font-mono">v{version.version}</h3>
{isLatest ? (
<Badge variant="default" className="text-xs">
Atual
</Badge>
) : null}
<time
className="font-mono text-xs uppercase tracking-wider text-muted-foreground"
dateTime={version.isoDate}
>
{validDate
? format(date, "dd MMM, yyyy", { locale: ptBR }).toUpperCase()
: version.date}
</time>
</div>
{version.summary ? (
<Card className="p-4">
<blockquote className="pl-2 text-sm text-muted-foreground leading-relaxed italic">
{version.summary}
</blockquote>
</Card>
) : null}
{hasDetails ? (
<Collapsible open={open} onOpenChange={onOpenChange}>
<CollapsibleTrigger asChild>
<Button
variant="link"
size="sm"
className="text-muted-foreground hover:text-foreground text-xs px-0"
>
<RiArrowDownSLine
className={cn(
"size-4 transition-transform",
open && "rotate-180",
)}
/>
{open ? "Ocultar detalhes" : "Ver detalhes"}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2 overflow-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2">
<VersionDetails version={version} />
</CollapsibleContent>
</Collapsible>
) : null}
</div>
</div>
);
}
export function ChangelogTab({ versions }: { versions: ChangelogVersion[] }) {
const [openVersions, setOpenVersions] = useState<Set<string>>(() => {
const initial = new Set<string>();
const first = versions[0]?.version;
if (first) initial.add(first);
return initial;
});
useEffect(() => {
if (typeof window === "undefined") return;
const hash = window.location.hash.slice(1);
if (!hash) return;
const target = anchorIdToVersion(hash);
if (target) {
setOpenVersions((prev) => {
if (prev.has(target)) return prev;
const next = new Set(prev);
next.add(target);
return next;
});
}
requestAnimationFrame(() => {
const el = document.getElementById(hash);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
});
}, []);
const groups = useMemo(() => groupByMonth(versions), [versions]);
const latestVersion = versions[0]?.version;
const setVersionOpen = (version: string, isOpen: boolean) => {
setOpenVersions((prev) => {
const next = new Set(prev);
if (isOpen) next.add(version);
else next.delete(version);
return next;
});
};
return (
<div className="space-y-4">
{versions.map((version) => (
<Card key={version.version} className="p-6">
<div className="flex items-baseline gap-3">
<h3 className="text-lg font-semibold">v{version.version}</h3>
<span className="text-sm text-muted-foreground">
{version.date}
</span>
</div>
<div className="space-y-4 w-full mx-auto sm:w-3/4">
{version.summary && (
<p className="border-l-2 border-muted-foreground/25 pl-3 text-sm text-muted-foreground/80 leading-relaxed italic">
{version.summary}
</p>
)}
{version.sections.map((section) => (
<div key={section.type}>
<Badge
variant={getSectionVariant(section.type)}
className="mb-2"
>
{section.type}
</Badge>
<ul className="space-y-2 text-muted-foreground leading-relaxed text-pretty">
{section.items.map((item) => (
<li key={item} className="flex gap-2">
<span className="text-primary">&bull;</span>
<span className="text-sm">{item}</span>
</li>
))}
</ul>
</div>
<div className="space-y-8 max-w-4xl mx-auto">
{groups.map((group) => (
<div key={group.key} className="space-y-4">
<h2 className="sticky top-0 z-10 py-2 font-semibold uppercase text-primary">
{group.label}
</h2>
<div>
{group.items.map((version) => (
<TimelineItem
key={version.version}
version={version}
isLatest={version.version === latestVersion}
open={openVersions.has(version.version)}
onOpenChange={(o) => setVersionOpen(version.version, o)}
/>
))}
{version.contributor && (
<div className="border-t pt-4 mt-4">
<span className="text-sm text-muted-foreground">
Contribuições: {(() => {
const { label, url } = parseContributorLine(
version.contributor,
);
if (url) {
return (
<Link
href={url}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-foreground underline underline-offset-2 hover:text-primary"
>
{label}
</Link>
);
}
return (
<span className="font-medium text-foreground">
{label}
</span>
);
})()}
</span>
</div>
)}
</div>
</Card>
</div>
))}
</div>
);