mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: add support for technical details in changelog entries
This commit is contained in:
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { CheckIcon, ClipboardIcon } from "lucide-react";
|
import { CheckIcon, ClipboardIcon } from "lucide-react";
|
||||||
import { getSectionTitle, groupAndOrderChanges } from "../utils";
|
import {
|
||||||
|
getSectionTitle,
|
||||||
|
groupAndOrderChanges,
|
||||||
|
isSectionCollapsible,
|
||||||
|
} from "../utils";
|
||||||
import type { Change } from "../utils";
|
import type { Change } from "../utils";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -23,6 +27,19 @@ function buildMarkdown({
|
|||||||
const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
|
const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
|
||||||
|
|
||||||
for (const type of orderedTypes) {
|
for (const type of orderedTypes) {
|
||||||
|
if (isSectionCollapsible({ type })) {
|
||||||
|
lines.push(
|
||||||
|
"<details>",
|
||||||
|
`<summary>${getSectionTitle({ type })}</summary>`,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
for (const change of grouped[type]) {
|
||||||
|
lines.push(`- ${change.text}`);
|
||||||
|
}
|
||||||
|
lines.push("", "</details>", "");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
lines.push(`## ${getSectionTitle({ type })}`);
|
lines.push(`## ${getSectionTitle({ type })}`);
|
||||||
for (const change of grouped[type]) {
|
for (const change of grouped[type]) {
|
||||||
lines.push(`- ${change.text}`);
|
lines.push(`- ${change.text}`);
|
||||||
@@ -54,7 +71,10 @@ export function CopyMarkdownButton({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="text"
|
variant="text"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
className={cn("flex items-center gap-1.5", copied && "pointer-events-none")}
|
className={cn(
|
||||||
|
"flex items-center gap-1.5",
|
||||||
|
copied && "pointer-events-none",
|
||||||
|
)}
|
||||||
title="Copy as markdown"
|
title="Copy as markdown"
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
import { getSectionTitle, groupAndOrderChanges } from "../utils";
|
import {
|
||||||
import type { Release } from "../utils";
|
getSectionTitle,
|
||||||
|
groupAndOrderChanges,
|
||||||
|
isSectionCollapsible,
|
||||||
|
type Change,
|
||||||
|
type Release,
|
||||||
|
} from "../utils";
|
||||||
|
import { ArrowRightIcon } from "@hugeicons/core-free-icons";
|
||||||
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
|
|
||||||
export function ReleaseArticle({
|
export function ReleaseArticle({
|
||||||
variant,
|
variant,
|
||||||
@@ -85,22 +92,61 @@ export function ReleaseChanges({ release }: { release: Release }) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{orderedTypes.map((type) => (
|
{orderedTypes.map((type) => (
|
||||||
<div key={type} className="flex flex-col gap-1.5">
|
<ReleaseChangeSection key={type} type={type} changes={grouped[type]} />
|
||||||
<h3 className="text-base font-semibold text-foreground">
|
|
||||||
{getSectionTitle({ type })}:
|
|
||||||
</h3>
|
|
||||||
<ul className="list-disc pl-5 space-y-1.5">
|
|
||||||
{grouped[type].map((change) => (
|
|
||||||
<li
|
|
||||||
key={change.text}
|
|
||||||
className="text-base text-foreground leading-relaxed"
|
|
||||||
>
|
|
||||||
{change.text}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ReleaseChangeSection({
|
||||||
|
type,
|
||||||
|
changes,
|
||||||
|
}: {
|
||||||
|
type: string;
|
||||||
|
changes: Change[];
|
||||||
|
}) {
|
||||||
|
const title = getSectionTitle({ type });
|
||||||
|
|
||||||
|
if (isSectionCollapsible({ type })) {
|
||||||
|
return (
|
||||||
|
<details className="group flex flex-col gap-1.5">
|
||||||
|
<summary className="flex w-fit cursor-pointer list-none items-center gap-1.5 text-base font-semibold text-foreground [&::-webkit-details-marker]:hidden">
|
||||||
|
<span>{title}:</span>
|
||||||
|
<HugeiconsIcon
|
||||||
|
icon={ArrowRightIcon}
|
||||||
|
className="size-3 shrink-0 text-muted-foreground group-open:rotate-90"
|
||||||
|
/>
|
||||||
|
</summary>
|
||||||
|
<ReleaseChangeList changes={changes} />
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<h3 className="text-base font-semibold text-foreground">{title}:</h3>
|
||||||
|
<ReleaseChangeList changes={changes} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReleaseChangeList({
|
||||||
|
changes,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
changes: Change[];
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ul className={cn("list-disc space-y-1.5 pl-5", className)}>
|
||||||
|
{changes.map((change) => (
|
||||||
|
<li
|
||||||
|
key={change.text}
|
||||||
|
className="text-base leading-relaxed text-foreground"
|
||||||
|
>
|
||||||
|
{change.text}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,35 +3,64 @@ import { allChangelogs } from "content-collections";
|
|||||||
export type Change = { type: string; text: string };
|
export type Change = { type: string; text: string };
|
||||||
export type Release = (typeof allChangelogs)[number];
|
export type Release = (typeof allChangelogs)[number];
|
||||||
|
|
||||||
const knownSectionOrder = ["new", "improved", "fixed", "breaking"];
|
type ChangeSectionConfig = {
|
||||||
|
title: string;
|
||||||
const knownSectionTitles: Record<string, string> = {
|
order: number;
|
||||||
new: "Features",
|
collapsible?: boolean;
|
||||||
improved: "Improvements",
|
|
||||||
fixed: "Fixes",
|
|
||||||
breaking: "Breaking Changes",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getSectionTitle({ type }: { type: string }): string {
|
const knownSectionConfigs: Record<string, ChangeSectionConfig> = {
|
||||||
|
new: { title: "Features", order: 0 },
|
||||||
|
improved: { title: "Improvements", order: 1 },
|
||||||
|
fixed: { title: "Fixes", order: 2 },
|
||||||
|
breaking: { title: "Breaking Changes", order: 3 },
|
||||||
|
technical: {
|
||||||
|
title: "Technical details",
|
||||||
|
order: 4,
|
||||||
|
collapsible: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function getSectionConfig({ type }: { type: string }): ChangeSectionConfig {
|
||||||
return (
|
return (
|
||||||
knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1)
|
knownSectionConfigs[type] ?? {
|
||||||
|
title: type.charAt(0).toUpperCase() + type.slice(1),
|
||||||
|
order: Number.MAX_SAFE_INTEGER,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSectionTitle({ type }: { type: string }): string {
|
||||||
|
return getSectionConfig({ type }).title;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSectionCollapsible({ type }: { type: string }): boolean {
|
||||||
|
return getSectionConfig({ type }).collapsible ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
export function groupAndOrderChanges({ changes }: { changes: Change[] }) {
|
export function groupAndOrderChanges({ changes }: { changes: Change[] }) {
|
||||||
|
const typeEncounterOrder = new Map<string, number>();
|
||||||
const grouped = changes.reduce<Record<string, Change[]>>((acc, change) => {
|
const grouped = changes.reduce<Record<string, Change[]>>((acc, change) => {
|
||||||
|
if (!typeEncounterOrder.has(change.type)) {
|
||||||
|
typeEncounterOrder.set(change.type, typeEncounterOrder.size);
|
||||||
|
}
|
||||||
if (!acc[change.type]) acc[change.type] = [];
|
if (!acc[change.type]) acc[change.type] = [];
|
||||||
acc[change.type].push(change);
|
acc[change.type].push(change);
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
const customTypes = Object.keys(grouped).filter(
|
const orderedTypes = Object.keys(grouped).sort((left, right) => {
|
||||||
(type) => !knownSectionOrder.includes(type),
|
const orderDifference =
|
||||||
);
|
getSectionConfig({ type: left }).order -
|
||||||
const orderedTypes = [
|
getSectionConfig({ type: right }).order;
|
||||||
...knownSectionOrder.filter((type) => grouped[type]?.length > 0),
|
if (orderDifference !== 0) {
|
||||||
...customTypes,
|
return orderDifference;
|
||||||
];
|
}
|
||||||
|
return (
|
||||||
|
(typeEncounterOrder.get(left) ?? Number.MAX_SAFE_INTEGER) -
|
||||||
|
(typeEncounterOrder.get(right) ?? Number.MAX_SAFE_INTEGER)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
return { grouped, orderedTypes };
|
return { grouped, orderedTypes };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user