mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-06-10 07:16:01 +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>
|
||||
);
|
||||
|
||||
30
src/features/settings/lib/changelog-types.ts
Normal file
30
src/features/settings/lib/changelog-types.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export type SectionType = "Adicionado" | "Alterado" | "Corrigido" | "Removido";
|
||||
|
||||
export const SECTION_TYPES: readonly SectionType[] = [
|
||||
"Adicionado",
|
||||
"Alterado",
|
||||
"Corrigido",
|
||||
"Removido",
|
||||
];
|
||||
|
||||
export type ChangelogSection = {
|
||||
type: SectionType;
|
||||
items: string[];
|
||||
};
|
||||
|
||||
export type BumpType = "major" | "minor" | "patch";
|
||||
|
||||
export type ChangelogVersion = {
|
||||
version: string;
|
||||
/** Formato exibido "DD/MM/YYYY". */
|
||||
date: string;
|
||||
/** Data ISO crua "YYYY-MM-DD" para ordenação e formatação client-side. */
|
||||
isoDate: string;
|
||||
bump: BumpType;
|
||||
summary?: string;
|
||||
sections: ChangelogSection[];
|
||||
};
|
||||
|
||||
export function isSectionType(value: string): value is SectionType {
|
||||
return (SECTION_TYPES as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -1,21 +1,27 @@
|
||||
import "server-only";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
type BumpType,
|
||||
type ChangelogSection,
|
||||
type ChangelogVersion,
|
||||
isSectionType,
|
||||
} from "@/features/settings/lib/changelog-types";
|
||||
|
||||
type ChangelogSection = {
|
||||
type: string;
|
||||
items: string[];
|
||||
};
|
||||
function diffBump(current: string, previous: string | undefined): BumpType {
|
||||
if (!previous) return "minor";
|
||||
const [aMajor = 0, aMinor = 0] = current.split(".").map(Number);
|
||||
const [bMajor = 0, bMinor = 0] = previous.split(".").map(Number);
|
||||
if (aMajor !== bMajor) return "major";
|
||||
if (aMinor !== bMinor) return "minor";
|
||||
return "patch";
|
||||
}
|
||||
|
||||
export type ChangelogVersion = {
|
||||
version: string;
|
||||
date: string;
|
||||
summary?: string;
|
||||
sections: ChangelogSection[];
|
||||
/** Linha de contribuições/autor (pode conter markdown, ex: [Nome](url)) */
|
||||
contributor?: string;
|
||||
};
|
||||
let cached: ChangelogVersion[] | null = null;
|
||||
|
||||
export function parseChangelog(): ChangelogVersion[] {
|
||||
if (cached) return cached;
|
||||
|
||||
const filePath = path.join(process.cwd(), "CHANGELOG.md");
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
@@ -31,10 +37,14 @@ export function parseChangelog(): ChangelogVersion[] {
|
||||
if (currentSection && currentVersion) {
|
||||
currentVersion.sections.push(currentSection);
|
||||
}
|
||||
const [y, m, d] = versionMatch[2].split("-");
|
||||
const version = versionMatch[1] ?? "";
|
||||
const isoDate = versionMatch[2] ?? "";
|
||||
const [y, m, d] = isoDate.split("-");
|
||||
currentVersion = {
|
||||
version: versionMatch[1],
|
||||
date: d && m && y ? `${d}/${m}/${y}` : versionMatch[2],
|
||||
version,
|
||||
isoDate,
|
||||
date: d && m && y ? `${d}/${m}/${y}` : isoDate,
|
||||
bump: "patch",
|
||||
sections: [],
|
||||
};
|
||||
versions.push(currentVersion);
|
||||
@@ -52,33 +62,35 @@ export function parseChangelog(): ChangelogVersion[] {
|
||||
if (currentSection) {
|
||||
currentVersion.sections.push(currentSection);
|
||||
}
|
||||
currentSection = { type: sectionMatch[1], items: [] };
|
||||
const type = sectionMatch[1] ?? "";
|
||||
currentSection = isSectionType(type) ? { type, items: [] } : null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemMatch = line.match(/^- (.+)$/);
|
||||
if (itemMatch && currentSection) {
|
||||
currentSection.items.push(itemMatch[1]);
|
||||
currentSection.items.push(itemMatch[1] ?? "");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentVersion && !currentSection && line.trim()) {
|
||||
summaryLines.push(line.trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
// **Contribuições:** ou **Autor:** com texto/link opcional
|
||||
const contributorMatch = line.match(
|
||||
/^\*\*(?:Contribuições|Autor):\*\*\s*(.+)$/,
|
||||
);
|
||||
if (contributorMatch && currentVersion) {
|
||||
currentVersion.contributor = contributorMatch[1].trim() || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSection && currentVersion) {
|
||||
currentVersion.sections.push(currentSection);
|
||||
}
|
||||
if (currentVersion && !currentVersion.summary && summaryLines.length > 0) {
|
||||
currentVersion.summary = summaryLines.join(" ").trim();
|
||||
}
|
||||
|
||||
for (let i = 0; i < versions.length; i++) {
|
||||
const current = versions[i];
|
||||
if (!current) continue;
|
||||
current.bump = diffBump(current.version, versions[i + 1]?.version);
|
||||
}
|
||||
|
||||
cached = versions;
|
||||
return versions;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user