[Dashboard] Introduce changelog to stack-companion (#1090)

<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Changelog panel now fetches and displays recent releases with rich
Markdown rendering, per-release cards, and change-type labels.
* Visual cues (badge, glow, tooltip) indicate when unseen updates are
available; last-seen state tracked for users.

* **Chores**
* Configured external changelog data source and added a backend endpoint
to serve parsed changelog entries.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Madison 2026-01-29 12:22:24 -06:00 committed by GitHub
parent 7b5cf4f042
commit b32eb9e351
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 552 additions and 259 deletions

View File

@ -2,6 +2,8 @@ NEXT_PUBLIC_STACK_API_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}0
NEXT_PUBLIC_STACK_DASHBOARD_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01
STACK_SERVER_SECRET=23-wuNpik0gIW4mruTz25rbIvhuuvZFrLOLtL7J4tyo
STACK_CHANGELOG_URL=https://raw.githubusercontent.com/stack-auth/stack-auth/refs/heads/dev/CHANGELOG.md
STACK_SEED_ENABLE_DUMMY_PROJECT=true
STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED=true
STACK_SEED_INTERNAL_PROJECT_OTP_ENABLED=true

View File

@ -0,0 +1,179 @@
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
const REVALIDATE_SECONDS = 60 * 60;
type ChangeType = "major" | "minor" | "patch";
type ChangelogEntry = {
version: string,
type: ChangeType,
markdown: string,
bulletCount: number,
releasedAt?: string,
isUnreleased?: boolean,
};
type TaggedBullet = { text: string, tags: string[] };
function parseTaggedBullet(line: string): TaggedBullet {
let content = line.replace(/^- /, "").trim();
const tags: string[] = [];
while (content.startsWith("[")) {
const closingIndex = content.indexOf("]");
if (closingIndex === -1) break;
const tag = content.slice(1, closingIndex).trim();
if (!tag) break;
tags.push(tag);
content = content.slice(closingIndex + 1).trim();
}
return { text: content, tags };
}
function parseVersionHeading(raw: string): { version: string, releasedAt?: string, isUnreleased: boolean } {
const normalized = raw.trim();
const isUnreleased = normalized.toLowerCase() === "unreleased";
if (isUnreleased) {
return { version: "Unreleased", isUnreleased: true };
}
const datePattern = /^(\d+\.\d+\.\d+)\s*(?:\(|-)\s*(\d{4}-\d{2}-\d{2})\)?$/;
const match = normalized.match(datePattern);
if (match) {
return {
version: match[1],
releasedAt: match[2],
isUnreleased: false,
};
}
return {
version: normalized,
isUnreleased: false,
};
}
function parseRootChangelog(markdown: string): ChangelogEntry[] {
const entries: ChangelogEntry[] = [];
const sections = markdown.split(/(?=^## .+)/m);
for (const section of sections) {
if (!section.trim()) continue;
const versionMatch = section.match(/^## (.+)/m);
if (!versionMatch) continue;
const heading = versionMatch[1].trim();
const { version, releasedAt, isUnreleased } = parseVersionHeading(heading);
const isSemver = /^\d+\.\d+\.\d+$/.test(version);
const isCalVer = /^\d{4}\.\d{2}\.\d{2}$/.test(version);
if (!isUnreleased && !isSemver && !isCalVer) {
continue;
}
const versionContent = section.replace(/^## .+$/m, "").trim();
let type: ChangeType = "patch";
if (versionContent.includes("### Major Changes")) type = "major";
else if (versionContent.includes("### Minor Changes")) type = "minor";
const lines = versionContent.split("\n");
const processedLines: string[] = [];
for (const line of lines) {
if (line.trim().startsWith("- ")) {
const { text } = parseTaggedBullet(line);
processedLines.push(text ? `- ${text}` : "-");
} else {
processedLines.push(line);
}
}
const normalizedMarkdown = processedLines.join("\n").trim();
const bulletCount = processedLines.filter(l => l.trim().startsWith("-")).length;
entries.push({
version,
type,
markdown: normalizedMarkdown,
bulletCount,
isUnreleased,
releasedAt,
});
}
return entries;
}
const changelogEntrySchema = yupObject({
version: yupString().defined(),
type: yupString().oneOf(["major", "minor", "patch"]).defined(),
markdown: yupString().defined(),
bulletCount: yupNumber().defined(),
releasedAt: yupString().optional(),
isUnreleased: yupBoolean().optional(),
}).defined();
export const GET = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
method: yupString().oneOf(["GET"]).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200, 502]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
entries: yupArray(changelogEntrySchema).optional(),
error: yupString().optional(),
}).defined(),
}),
handler: async () => {
const changelogUrl = getEnvVariable("STACK_CHANGELOG_URL", "");
if (!changelogUrl) {
return {
statusCode: 200,
bodyType: "json",
body: { entries: [] },
} as const;
}
const response = await fetch(changelogUrl, {
headers: {
"Accept": "text/plain",
"User-Agent": "stack-auth-backend-changelog",
},
next: {
revalidate: REVALIDATE_SECONDS,
},
});
if (!response.ok) {
return {
statusCode: 502,
bodyType: "json",
body: { error: "Failed to download changelog" },
} as const;
}
const content = await response.text();
const entries = parseRootChangelog(content).slice(0, 8);
return {
statusCode: 200,
bodyType: "json",
body: { entries },
} as const;
},
});

View File

@ -16,3 +16,4 @@ STACK_DEVELOPMENT_TRANSLATION_LOCALE=# enter the locale to use for the translati
NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS='["internal"]'
NEXT_PUBLIC_STACK_DEBUGGER_ON_ASSERTION_ERROR=# set to true to open the debugger on assertion errors (set to true in .env.development)
STACK_FEATUREBASE_JWT_SECRET=# used for Featurebase SSO, you probably won't have to set this
STACK_CHANGELOG_URL=# Used for raw github link to root changelog.md file.

View File

@ -1,9 +1,12 @@
'use client';
import { Button, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui';
import { ChangelogEntry } from '@/lib/changelog';
import { getPublicEnvVar } from '@/lib/env';
import { cn } from '@/lib/utils';
import { checkVersion, VersionCheckResult } from '@/lib/version-check';
import { BookOpenIcon, CircleNotchIcon, ClockClockwiseIcon, LightbulbIcon, XIcon } from '@phosphor-icons/react';
import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises';
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
import packageJson from '../../package.json';
import { FeedbackForm } from './feedback-form';
@ -11,6 +14,38 @@ import { ChangelogWidget } from './stack-companion/changelog-widget';
import { FeatureRequestBoard } from './stack-companion/feature-request-board';
import { UnifiedDocsWidget } from './stack-companion/unified-docs-widget';
/**
* Compare two CalVer versions in YYYY.MM.DD format
* Returns true if version1 is newer than version2
*/
function isNewerCalVer(version1: string, version2: string): boolean {
const parseCalVer = (version: string): Date | null => {
const match = version.match(/^(\d{4})\.(\d{2})\.(\d{2})$/);
if (!match) return null;
const [, year, month, day] = match;
return new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
};
const date1 = parseCalVer(version1);
const date2 = parseCalVer(version2);
if (!date1 || !date2) {
// Fallback to string comparison if parsing fails
return version1 > version2;
}
return date1.getTime() > date2.getTime();
}
/**
* Sanitize a string value for use in a cookie
* Removes or encodes characters that could break cookie parsing
*/
function sanitizeCookieValue(value: string): string {
// Remove or encode special characters that break cookie parsing
return encodeURIComponent(value);
}
type SidebarItem = {
id: string,
label: string,
@ -73,6 +108,7 @@ export function useStackCompanion() {
return useContext(StackCompanionContext);
}
export function StackCompanion({ className }: { className?: string }) {
const [activeItem, setActiveItem] = useState<string | null>(null);
const [mounted, setMounted] = useState(false);
@ -82,6 +118,9 @@ export function StackCompanion({ className }: { className?: string }) {
const [isAnimating, setIsAnimating] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [isSplitScreenMode, setIsSplitScreenMode] = useState(false);
const [changelogData, setChangelogData] = useState<ChangelogEntry[] | undefined>(undefined);
const [hasNewVersions, setHasNewVersions] = useState(false);
const [lastSeenVersion, setLastSeenVersion] = useState('');
const startXRef = useRef(0);
const startWidthRef = useRef(0);
@ -125,6 +164,84 @@ export function StackCompanion({ className }: { className?: string }) {
return cleanup;
}, []);
// Fetch changelog data on mount and check for new versions
useEffect(() => {
runAsynchronously(async () => {
const baseUrl = getPublicEnvVar('NEXT_PUBLIC_STACK_API_URL') || '';
const response = await fetch(`${baseUrl}/api/latest/internal/changelog`);
if (!response.ok) {
return;
}
const payload = await response.json();
const entries = payload.entries || [];
setChangelogData(entries);
// Check for new versions
const lastSeenRaw = document.cookie
.split('; ')
.find(row => row.startsWith('stack-last-seen-changelog-version='))
?.split('=')[1] || '';
const lastSeen = lastSeenRaw ? decodeURIComponent(lastSeenRaw) : '';
setLastSeenVersion(lastSeen);
if (entries.length > 0) {
// If no lastSeen cookie, user hasn't seen any changelog yet - show bell
if (!lastSeen) {
setHasNewVersions(true);
} else {
const hasNewer = entries.some((entry: ChangelogEntry) => {
if (entry.isUnreleased) return false;
return isNewerCalVer(entry.version, lastSeen);
});
setHasNewVersions(hasNewer);
}
}
});
}, []);
// Re-check for new versions when changelog is opened/closed
useEffect(() => {
if (activeItem === 'changelog') {
// When changelog is opened, mark the latest released version as seen
// Skip unreleased versions to avoid breaking version comparison
if (changelogData && changelogData.length > 0) {
const latestReleasedEntry = changelogData.find(entry => !entry.isUnreleased);
if (latestReleasedEntry) {
document.cookie = `stack-last-seen-changelog-version=${sanitizeCookieValue(latestReleasedEntry.version)}; path=/; max-age=31536000`; // 1 year
setLastSeenVersion(latestReleasedEntry.version);
}
}
// Clear the notification badge immediately
setHasNewVersions(false);
} else if (activeItem === null) {
// When closed, re-check if there are new versions
const lastSeenRaw = document.cookie
.split('; ')
.find(row => row.startsWith('stack-last-seen-changelog-version='))
?.split('=')[1] || '';
const lastSeen = lastSeenRaw ? decodeURIComponent(lastSeenRaw) : '';
if (changelogData && changelogData.length > 0) {
// If no lastSeen cookie, user hasn't seen any changelog yet - show bell
if (!lastSeen) {
setHasNewVersions(true);
} else {
const hasNewer = changelogData.some((entry: ChangelogEntry) => {
if (entry.isUnreleased) return false;
return isNewerCalVer(entry.version, lastSeen);
});
setHasNewVersions(hasNewer);
}
} else {
setHasNewVersions(false);
}
}
}, [activeItem, changelogData]);
const openDrawer = useCallback((itemId: string) => {
setActiveItem(itemId);
setIsAnimating(true);
@ -304,7 +421,7 @@ export function StackCompanion({ className }: { className?: string }) {
<div className="flex-1 overflow-y-auto p-5 overflow-x-hidden no-drag cursor-auto">
{activeItem === 'docs' && <UnifiedDocsWidget isActive={true} />}
{activeItem === 'feedback' && <FeatureRequestBoard isActive={true} />}
{activeItem === 'changelog' && <ChangelogWidget isActive={true} />}
{activeItem === 'changelog' && <ChangelogWidget isActive={true} initialData={changelogData} />}
{activeItem === 'support' && <FeedbackForm />}
</div>
</div>
@ -338,7 +455,9 @@ export function StackCompanion({ className }: { className?: string }) {
className={cn(
"h-10 w-10 p-0 text-muted-foreground transition-all duration-[50ms] rounded-xl relative group",
item.hoverBg,
activeItem === item.id && "bg-foreground/10 text-foreground shadow-sm ring-1 ring-foreground/5"
activeItem === item.id && "bg-foreground/10 text-foreground shadow-sm ring-1 ring-foreground/5",
// Glow effect for changelog with new updates
item.id === 'changelog' && hasNewVersions && "ring-2 ring-green-500/30 bg-green-500/10"
)}
onClick={(e) => {
e.stopPropagation();
@ -346,10 +465,16 @@ export function StackCompanion({ className }: { className?: string }) {
}}
>
<item.icon className={cn("h-5 w-5 transition-transform duration-[50ms] group-hover:scale-110", item.color)} />
{item.id === 'changelog' && hasNewVersions && (
<span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
</span>
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left" className="z-[60] mr-2">
{item.label}
{item.id === 'changelog' && hasNewVersions ? `${item.label} (New updates available!)` : item.label}
</TooltipContent>
</Tooltip>
))}

View File

@ -1,285 +1,261 @@
'use client';
import { Button } from '@/components/ui';
import { CalendarIcon, CaretDownIcon, CaretUpIcon } from '@phosphor-icons/react';
import { getPublicEnvVar } from '@/lib/env';
import { CalendarIcon, CaretDownIcon, CaretUpIcon, InfoIcon } from '@phosphor-icons/react';
import { captureError } from '@stackframe/stack-shared/dist/utils/errors';
import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises';
import Image from 'next/image';
import Script from 'next/script';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
type ChangeType = 'major' | 'minor' | 'patch';
type ApiChangelogEntry = {
version: string,
type: ChangeType,
markdown: string,
bulletCount: number,
releasedAt?: string,
isUnreleased?: boolean,
};
type ChangelogItem = ApiChangelogEntry & {
id: string,
expanded: boolean,
};
type ChangelogWidgetProps = {
isActive: boolean,
initialData?: ApiChangelogEntry[],
};
type ChangelogItem = {
id: string,
title: string,
content: string,
date: string,
featuredImage?: string,
isNew?: boolean,
expanded?: boolean,
function toChangelogItems(entries: ApiChangelogEntry[]): ChangelogItem[] {
return entries.map((entry, index) => ({
...entry,
id: `${entry.version}-${entry.releasedAt ?? 'unreleased'}`,
expanded: index === 0,
}));
}
function markLatestVersionSeen(entries: ApiChangelogEntry[]) {
// Find the first released version (skip unreleased to avoid breaking version comparison)
const latestReleasedEntry = entries.find(entry => !entry.isUnreleased);
if (latestReleasedEntry) {
document.cookie = `stack-last-seen-changelog-version=${encodeURIComponent(latestReleasedEntry.version)}; path=/; max-age=31536000`;
}
}
const formatVersion = (version: string) => {
// Convert YYYY.MM.DD to YY.MM.DD format for display
const calVerMatch = version.match(/^(\d{4})\.(\d{2})\.(\d{2})$/);
if (calVerMatch) {
const [, year, month, day] = calVerMatch;
const shortYear = year.slice(-2); // Get last 2 digits
return `${shortYear}.${month}.${day}`;
}
return version;
};
export function ChangelogWidget({ isActive }: ChangelogWidgetProps) {
const [changelogs, setChangelogs] = useState<ChangelogItem[]>([]);
const [loading, setLoading] = useState(true);
// Markdown component overrides for changelog rendering
const markdownComponents = {
h3: ({ children }: { children?: React.ReactNode }) => (
<h3 className="text-sm font-semibold text-foreground mt-4 mb-2 first:mt-0">
{children}
</h3>
),
p: ({ children }: { children?: React.ReactNode }) => (
<p className="text-muted-foreground leading-relaxed mb-2 last:mb-0">
{children}
</p>
),
ul: ({ children }: { children?: React.ReactNode }) => (
<ul className="list-disc list-outside ml-4 space-y-1 text-muted-foreground">
{children}
</ul>
),
li: ({ children }: { children?: React.ReactNode }) => (
<li className="leading-relaxed">
{children}
</li>
),
code: ({ children }: { children?: React.ReactNode }) => (
<code className="px-1.5 py-0.5 rounded bg-muted text-foreground text-xs font-mono">
{children}
</code>
),
blockquote: ({ children }: { children?: React.ReactNode }) => (
<div className="bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 p-3 my-3 rounded-md">
<div className="flex items-start gap-2">
<InfoIcon className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-800 dark:text-blue-200 leading-relaxed [&>p]:mb-0">
{children}
</div>
</div>
</div>
),
img: ({ src, alt, title }: { src?: string, alt?: string, title?: string }) => {
if (!src) return null;
return (
<Image
src={src}
alt={alt || ''}
title={title}
width={800}
height={600}
className="rounded-lg border border-border max-w-full h-auto my-4"
/>
);
},
};
export function ChangelogWidget({ isActive, initialData }: ChangelogWidgetProps) {
const [changelog, setChangelog] = useState<ChangelogItem[]>([]);
const [loading, setLoading] = useState(!initialData);
const [error, setError] = useState<string | null>(null);
const hasFetchedRef = useRef(false);
const fetchChangelog = useCallback(async (signal?: AbortSignal) => {
try {
setLoading(true);
setError(null);
const baseUrl = getPublicEnvVar('NEXT_PUBLIC_STACK_API_URL') || '';
const response = await fetch(`${baseUrl}/api/latest/internal/changelog`, { signal });
if (!response.ok) {
throw new Error(`Failed to fetch changelog: ${response.status}`);
}
const payload = await response.json();
const entries: ApiChangelogEntry[] = payload.entries || [];
setChangelog(toChangelogItems(entries));
markLatestVersionSeen(entries);
} catch (cause) {
if (signal?.aborted) {
return;
}
captureError('changelog-fetching', cause);
setError('Unable to load the changelog right now.');
setChangelog([]);
} finally {
if (!signal?.aborted) {
setLoading(false);
}
}
}, []);
useEffect(() => {
if (!isActive || hasFetchedRef.current) {
return;
}
hasFetchedRef.current = true;
if (initialData !== undefined) {
setChangelog(toChangelogItems(initialData));
setLoading(false);
markLatestVersionSeen(initialData);
} else {
const abortController = new AbortController();
runAsynchronously(fetchChangelog(abortController.signal));
return () => abortController.abort();
}
}, [fetchChangelog, isActive, initialData]);
const toggleExpanded = (id: string) => {
setChangelogs(prev => prev.map(changelog =>
changelog.id === id
? { ...changelog, expanded: !changelog.expanded }
: changelog
setChangelog((prev) => prev.map((entry) =>
entry.id === id ? { ...entry, expanded: !entry.expanded } : entry,
));
};
// Helper function to determine if content should be collapsible
const shouldCollapseContent = (content: string) => {
const textContent = content.replace(/<[^>]*>/g, '');
return textContent.length > 200; // Collapse if text is longer than 200 characters
};
useEffect(() => {
if (!isActive) return;
const win = window as any;
if (typeof win.Featurebase !== "function") {
win.Featurebase = function () {
// eslint-disable-next-line prefer-rest-params
(win.Featurebase.q = win.Featurebase.q || []).push(arguments);
};
}
// Initialize the widget but disable popup since we're showing inline
win.Featurebase("init_changelog_widget", {
organization: "stackauth",
dropdown: {
enabled: false, // Disable since we're showing inline
},
popup: {
enabled: false, // Disable popup since we're showing inline
autoOpenForNewUpdates: false,
},
theme: "light",
locale: "en",
});
// Fetch changelog data directly from Featurebase API
const fetchChangelogs = async () => {
try {
setLoading(true);
const response = await fetch('https://stackauth.featurebase.app/api/v1/changelog', {
headers: {
'Accept': 'application/json',
},
});
if (response.ok) {
const data = await response.json();
// Transform the data to our format - API returns { results: [...] }
const transformedData = data.results?.slice(0, 10).map((item: any) => ({
id: item.id,
title: item.title,
content: item.content || 'No content available',
date: new Date(item.date).toLocaleDateString(),
featuredImage: item.featuredImage,
isNew: new Date(item.date) > new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // Consider new if within last 7 days
expanded: false, // Start collapsed
})) || [];
setChangelogs(transformedData);
} else {
console.error('Failed to fetch changelogs:', response.status, response.statusText);
setChangelogs([]);
}
} catch (error) {
console.error('Failed to fetch changelogs:', error);
setChangelogs([]);
} finally {
setLoading(false);
}
};
runAsynchronously(fetchChangelogs());
}, [isActive]);
if (loading) {
return (
<>
<Script src="https://do.featurebase.app/js/sdk.js" id="featurebase-sdk" />
<div className="space-y-4">
<div className="bg-muted/30 rounded-lg p-4">
<div className="animate-pulse space-y-3">
<div className="h-4 bg-muted rounded w-3/4"></div>
<div className="h-3 bg-muted rounded w-1/2"></div>
</div>
<div className="space-y-4">
<div className="bg-muted/30 rounded-lg p-4">
<div className="animate-pulse space-y-3">
<div className="h-4 bg-muted rounded w-3/4" />
<div className="h-3 bg-muted rounded w-1/2" />
</div>
</div>
</>
<div className="space-y-3">
{[0, 1, 2].map((item) => (
<div key={item} className="bg-card rounded-lg border border-border p-4">
<div className="animate-pulse space-y-3">
<div className="h-3 bg-muted rounded w-1/3" />
<div className="h-4 bg-muted rounded w-2/3" />
<div className="h-24 bg-muted rounded" />
</div>
</div>
))}
</div>
</div>
);
}
return (
<>
<Script src="https://do.featurebase.app/js/sdk.js" id="featurebase-sdk" />
<div className="space-y-4">
{/* Header section */}
<div className="bg-muted/30 rounded-lg p-4">
<h3 className="text-sm font-semibold mb-2">Latest Updates</h3>
<p className="text-xs text-muted-foreground">
Recent features and improvements
<div className="space-y-4">
<div className="bg-muted/30 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold">Stack Auth releases</h3>
</div>
</div>
{error && (
<p className="text-xs text-destructive mt-2">
{error}
</p>
</div>
{/* Changelog Items */}
<div className="space-y-4">
{changelogs.length > 0 ? (
changelogs.map((changelog) => {
const shouldCollapse = shouldCollapseContent(changelog.content);
return (
<div
key={changelog.id}
className="bg-card rounded-lg border border-border overflow-hidden"
>
{/* Featured Image with Title Overlay - Always Visible */}
{changelog.featuredImage ? (
<div className="relative">
<Image
src={changelog.featuredImage}
alt={changelog.title}
width={320}
height={128}
className="w-full h-32 object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
{/* Dark overlay for better text readability */}
<div className="absolute inset-0 bg-black/40"></div>
{/* Title and metadata overlay */}
<div className="absolute inset-0 p-3 flex flex-col justify-end">
<div className="flex items-start justify-between mb-1">
<h4 className="text-sm font-semibold text-white line-clamp-2 flex-1">
{changelog.title}
</h4>
{changelog.isNew && (
<span className="bg-primary text-primary-foreground text-xs px-2 py-0.5 rounded-full flex-shrink-0 ml-2">
New
</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-white/80">
<CalendarIcon className="h-3 w-3" />
<span>{changelog.date}</span>
</div>
</div>
</div>
) : (
/* Fallback header when no image */
<div className="p-3 border-b border-border bg-muted/20">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2 flex-1">
<h4 className="text-sm font-medium line-clamp-2">
{changelog.title}
</h4>
{changelog.isNew && (
<span className="bg-primary text-primary-foreground text-xs px-2 py-0.5 rounded-full flex-shrink-0">
New
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<CalendarIcon className="h-3 w-3" />
<span>{changelog.date}</span>
</div>
</div>
)}
{/* Content Section */}
<div className="p-3">
{shouldCollapse ? (
/* Collapsible content for long text */
<>
{!changelog.expanded && (
<div>
<p className="text-xs text-muted-foreground line-clamp-3 mb-2">
{changelog.content.replace(/<[^>]*>/g, '')}
</p>
<Button
variant="ghost"
size="sm"
className="h-6 text-xs p-0 text-primary hover:text-primary/80"
onClick={() => toggleExpanded(changelog.id)}
>
<CaretDownIcon className="h-3 w-3 mr-1" />
Read more
</Button>
</div>
)}
{changelog.expanded && (
<div>
<div
className="prose prose-sm max-w-none text-xs mb-3 whitespace-pre-wrap"
style={{
fontSize: '12px',
lineHeight: '1.4',
}}
>
{changelog.content.replace(/<[^>]*>/g, '')}
</div>
<Button
variant="ghost"
size="sm"
className="h-6 text-xs p-0 text-primary hover:text-primary/80"
onClick={() => toggleExpanded(changelog.id)}
>
<CaretUpIcon className="h-3 w-3 mr-1" />
Show less
</Button>
</div>
)}
</>
) : (
/* Always visible content for short text */
<div
className="prose prose-sm max-w-none text-xs whitespace-pre-wrap"
style={{
fontSize: '12px',
lineHeight: '1.4',
}}
>
{changelog.content.replace(/<[^>]*>/g, '')}
</div>
)}
</div>
</div>
);
})
) : (
<div className="bg-muted/30 rounded-lg p-4 text-center">
<CalendarIcon className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-xs text-muted-foreground font-medium">
No changelog updates available
</p>
<p className="text-xs text-muted-foreground/80 mt-1">
Check back later for new updates
</p>
</div>
)}
</div>
{/* Hidden Featurebase trigger for advanced features */}
<div className="hidden">
<button data-featurebase-changelog>
<span id="fb-update-badge"></span>
</button>
</div>
)}
</div>
</>
<div className="space-y-4">
{changelog.length === 0 && !error && (
<div className="bg-muted/30 rounded-lg p-4 text-center">
<CalendarIcon className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-xs text-muted-foreground font-medium">
No changelog entries found
</p>
</div>
)}
{changelog.map((entry) => (
<div key={entry.id} className="bg-card rounded-lg border border-border">
<div className="px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<h4 className="text-base font-semibold">v{formatVersion(entry.version)}</h4>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => toggleExpanded(entry.id)}
>
{entry.expanded ? (
<CaretUpIcon className="h-3 w-3" />
) : (
<CaretDownIcon className="h-3 w-3" />
)}
</Button>
</div>
{entry.expanded && (
<div className="px-4 pb-4">
<div className="text-sm leading-relaxed space-y-3">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{entry.markdown}
</ReactMarkdown>
</div>
</div>
)}
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,10 @@
export type ChangeType = "major" | "minor" | "patch";
export type ChangelogEntry = {
version: string,
type: ChangeType,
markdown: string,
bulletCount: number,
releasedAt?: string,
isUnreleased?: boolean,
};