mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
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:
@@ -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">•</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">•</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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user