mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
[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:
parent
7b5cf4f042
commit
b32eb9e351
@ -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
|
||||
|
||||
179
apps/backend/src/app/api/latest/internal/changelog/route.tsx
Normal file
179
apps/backend/src/app/api/latest/internal/changelog/route.tsx
Normal 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;
|
||||
},
|
||||
});
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
10
apps/dashboard/src/lib/changelog.ts
Normal file
10
apps/dashboard/src/lib/changelog.ts
Normal 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,
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user