feat(FN-188): add Changelog tab to dashboard settings
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled

Squash-recovery of fusion/fn-188 onto dev — original branch was based on
main (dccd4fa) due to baseBranch=None drift in Fusion settings; rebase
onto dev surfaced unrelated main-only commits (EMEX/PL24/Corgi) as false
conflicts. This commit applies only FN-188's 23-file changelog patch.

- apps/api: ChangelogModule (controller, service, DTO, spec, schema)
- apps/web: ChangelogTab (timeline + accordion), useChangelog hook, i18n
- packages/shared: changelog Zod schemas + types
- packages/ui: Accordion component + Badge stage variant
- docs/INDEX.md: changelog feature documented

Lint, typecheck, all 169 api tests + 2 web tests pass.
This commit is contained in:
Fusion
2026-05-11 20:05:16 +00:00
parent a7042dffb0
commit 85ebd35048
23 changed files with 739 additions and 13 deletions

View File

@@ -29,6 +29,18 @@ export {
forgotPasswordSchema,
resetPasswordSchema,
} from "./schemas/auth.js";
export {
changelogStageEnum,
changelogEntrySchema,
createChangelogEntrySchema,
updateChangelogEntrySchema,
} from "./schemas/changelog.js";
export type {
ChangelogEntry,
CreateChangelogEntry,
UpdateChangelogEntry,
ChangelogStage,
} from "./schemas/changelog.js";
export { paginationSchema } from "./schemas/pagination.js";
// Constants

View File

@@ -0,0 +1,27 @@
import { z } from "zod";
export const changelogStageEnum = z.enum(["alpha", "beta", "prod"]);
export const changelogEntrySchema = z.object({
id: z.string().uuid(),
stage: changelogStageEnum,
title: z.string().min(1).max(255),
description: z.string().min(1),
publishedAt: z.string().datetime(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export const createChangelogEntrySchema = z.object({
stage: changelogStageEnum,
title: z.string().min(1).max(255),
description: z.string().min(1),
publishedAt: z.string().datetime(),
});
export const updateChangelogEntrySchema = createChangelogEntrySchema.partial();
export type ChangelogEntry = z.infer<typeof changelogEntrySchema>;
export type CreateChangelogEntry = z.infer<typeof createChangelogEntrySchema>;
export type UpdateChangelogEntry = z.infer<typeof updateChangelogEntrySchema>;
export type ChangelogStage = z.infer<typeof changelogStageEnum>;

View File

@@ -0,0 +1,6 @@
export type {
ChangelogEntry,
CreateChangelogEntry,
UpdateChangelogEntry,
ChangelogStage,
} from "../schemas/changelog.js";

View File

@@ -15,6 +15,7 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.0",
"@radix-ui/react-label": "^2.1.0",

View File

@@ -0,0 +1,50 @@
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "./utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ComponentRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ComponentRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ComponentRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };

View File

@@ -12,6 +12,11 @@ const badgeVariants = cva(
destructive: "border-transparent bg-destructive text-destructive-foreground shadow",
outline: "text-foreground",
},
stage: {
alpha: "border-transparent bg-amber-500/15 text-amber-600 dark:bg-amber-500/20 dark:text-amber-400",
beta: "border-transparent bg-blue-500/15 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400",
prod: "border-transparent bg-emerald-500/15 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400",
},
},
defaultVariants: {
variant: "default",
@@ -23,8 +28,8 @@ export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
function Badge({ className, variant, stage, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant, stage }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -14,6 +14,7 @@ export { Skeleton } from "./skeleton";
export { Label } from "./label";
export { Separator } from "./separator";
export { Tabs, TabsList, TabsTrigger, TabsContent } from "./tabs";
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "./accordion";
export {
Dialog,
DialogPortal,