feat(FN-188): add Changelog tab to dashboard settings
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled
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:
113
apps/web/src/components/settings/changelog-tab.tsx
Normal file
113
apps/web/src/components/settings/changelog-tab.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useChangelog } from "@/hooks/use-changelog";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { ChangelogEntry } from "@sase/shared";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Badge,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Skeleton,
|
||||
} from "@sase/ui";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
|
||||
function formatDate(iso: string, locale: "tr" | "en"): string {
|
||||
const date = new Date(iso);
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
};
|
||||
return date.toLocaleDateString(locale === "tr" ? "tr-TR" : "en-US", options);
|
||||
}
|
||||
|
||||
function stageLabel(stage: ChangelogEntry["stage"], t: (key: string) => string): string {
|
||||
return t(`settings.changelog.stage.${stage}`);
|
||||
}
|
||||
|
||||
function ChangelogSkeleton() {
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="relative pb-6 pl-8">
|
||||
<div className="absolute left-0 top-1.5 h-2.5 w-2.5 rounded-full bg-muted-foreground/20" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-16 rounded-md" />
|
||||
<Skeleton className="h-5 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangelogEmpty({ t }: { t: (key: string) => string }) {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<CardHeader className="text-center">
|
||||
<CalendarDays className="mx-auto h-10 w-10 text-muted-foreground" />
|
||||
<CardTitle>{t("settings.changelog.emptyTitle")}</CardTitle>
|
||||
<CardDescription>{t("settings.changelog.emptyDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangelogTab() {
|
||||
const { data: entries, isLoading } = useChangelog();
|
||||
const { t, locale } = useTranslation();
|
||||
|
||||
if (isLoading) {
|
||||
return <ChangelogSkeleton />;
|
||||
}
|
||||
|
||||
if (!entries || entries.length === 0) {
|
||||
return <ChangelogEmpty t={t} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("settings.changelog.title")}</CardTitle>
|
||||
<CardDescription>{t("settings.changelog.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="relative border-l-2 border-muted-foreground/20"
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<AccordionItem key={entry.id} value={entry.id} className="border-b-0 pl-6">
|
||||
<div className="absolute left-[-5px] mt-6 h-2.5 w-2.5 rounded-full border-2 border-background bg-foreground dark:bg-primary" />
|
||||
<AccordionTrigger className="py-3 hover:no-underline">
|
||||
<div className="flex flex-col items-start gap-1.5 text-left">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge stage={entry.stage}>{stageLabel(entry.stage, t)}</Badge>
|
||||
<span className="font-medium">{entry.title}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(entry.publishedAt, locale)}
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{entry.description}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -21,8 +21,19 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Copy, Gift, Link2, Share2, Shield, Trash2, User } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CalendarDays,
|
||||
Copy,
|
||||
Gift,
|
||||
Link2,
|
||||
Share2,
|
||||
Shield,
|
||||
Trash2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChangelogTab } from "./changelog-tab";
|
||||
|
||||
export function SettingsContent() {
|
||||
const { t } = useTranslation();
|
||||
@@ -172,6 +183,10 @@ export function SettingsContent() {
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("settings.tabs.account")}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="changelog" className="gap-2">
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("settings.tabs.changelog")}</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Profile Tab */}
|
||||
@@ -454,6 +469,11 @@ export function SettingsContent() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Changelog Tab */}
|
||||
<TabsContent value="changelog">
|
||||
<ChangelogTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
--font-mono: "Geist Mono", ui-monospace, "SF Mono", monospace;
|
||||
--font-display: "Geist", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-serif: "Instrument Serif", ui-serif, Georgia, serif;
|
||||
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -149,6 +152,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-scroll-left {
|
||||
animation: scroll-left 30s linear infinite;
|
||||
}
|
||||
|
||||
11
apps/web/src/hooks/use-changelog.ts
Normal file
11
apps/web/src/hooks/use-changelog.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import type { ChangelogEntry } from "@sase/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export function useChangelog() {
|
||||
return useQuery({
|
||||
queryKey: ["changelog"],
|
||||
queryFn: () => api.get<ChangelogEntry[]>("/changelog"),
|
||||
staleTime: 30 * 60 * 1000, // 30 min — matches Redis cache TTL
|
||||
});
|
||||
}
|
||||
@@ -261,7 +261,8 @@
|
||||
"security": "Security",
|
||||
"connections": "Connections",
|
||||
"referral": "Referral",
|
||||
"account": "Account"
|
||||
"account": "Account",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profile Information",
|
||||
@@ -318,6 +319,17 @@
|
||||
"deleteFailed": "Account deletion failed.",
|
||||
"typeConfirm": "Type 'DELETE' to confirm",
|
||||
"confirmWord": "DELETE"
|
||||
},
|
||||
"changelog": {
|
||||
"title": "Changelog",
|
||||
"description": "Latest platform updates, new features, and bug fixes.",
|
||||
"emptyTitle": "No updates yet",
|
||||
"emptyDescription": "Platform updates will appear here soon.",
|
||||
"stage": {
|
||||
"alpha": "Alpha",
|
||||
"beta": "Beta",
|
||||
"prod": "Production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
|
||||
@@ -261,7 +261,8 @@
|
||||
"security": "Güvenlik",
|
||||
"connections": "Bağlantılar",
|
||||
"referral": "Referans",
|
||||
"account": "Hesap"
|
||||
"account": "Hesap",
|
||||
"changelog": "Değişiklik Günlüğü"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profil Bilgileri",
|
||||
@@ -318,6 +319,17 @@
|
||||
"deleteFailed": "Hesap silme başarısız.",
|
||||
"typeConfirm": "Onaylamak için 'SİL' yazın",
|
||||
"confirmWord": "SİL"
|
||||
},
|
||||
"changelog": {
|
||||
"title": "Değişiklik Günlüğü",
|
||||
"description": "Platformdaki son güncellemeler, yeni özellikler ve hata düzeltmeleri.",
|
||||
"emptyTitle": "Henüz güncelleme yok",
|
||||
"emptyDescription": "Yakında platform güncellemeleri burada görünecek.",
|
||||
"stage": {
|
||||
"alpha": "Alpha",
|
||||
"beta": "Beta",
|
||||
"prod": "Canlı"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Mock the PostHog capture function
|
||||
|
||||
Reference in New Issue
Block a user