[Changelog] Updates to US date format, adds images. (#1143)

<img width="509" height="858" alt="image"
src="https://github.com/user-attachments/assets/520a1a01-f13f-4e20-a3e7-9e47a777b507"
/>

Full image view support:
<img width="2345" height="924" alt="image"
src="https://github.com/user-attachments/assets/f22f7a83-fc47-4d37-a251-56f1fbb62c8f"
/>





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

* **New Features**
* Added interactive fullscreen image preview for changelog entries with
keyboard and click-to-close controls.

* **Improvements**
  * Unified changelog date format to US M/D/YY for consistent display.
* Broadened external image loading so changelog images from common hosts
display reliably.
  * Inserted image badge placeholders to enhance changelog visuals.

<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-02-02 11:21:21 -06:00 committed by GitHub
parent 56246fcdf6
commit 231b49308d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 219 additions and 136 deletions

View File

@ -2,28 +2,32 @@
---
## 2026.01.23
## 1/23/26
### Payments
Introduced a redesigned payments onboarding flow
![Payments Onboarding]()
## 2026.01.21
## 1/21/26
### Payments
- Payments page updated with new UI changes
![Create Product]()
- Added a new Payments Settings page with an option to temporarily disable all payments
![Payments Setting]()
- Subscription renewal emails are now sent automatically to users
- Past payment invoices are now visible on the Account Settings page
![Past Payments Invoices]()
### Documentation
- Updated JWT documentation to include `isRestricted` and `restrictedReason`
## 2026.01.19
## 1/19/26
- Updated package dependencies to their newest versions.
## 2025.12.19
## 12/19/25
- Introduces new changelog and deprecates all older changelogs.
- Moved away from semantic versioning in favor of CalVer.
- Date versioning for public view.
---

View File

@ -1,6 +1,8 @@
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";
import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
import * as fs from "fs/promises";
import * as path from "path";
const REVALIDATE_SECONDS = 60 * 60;
@ -72,10 +74,9 @@ function parseRootChangelog(markdown: string): ChangelogEntry[] {
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);
const isUsDate = /^\d{1,2}\/\d{1,2}\/\d{2}$/.test(version); // US date format: M/D/YY
if (!isUnreleased && !isSemver && !isCalVer) {
if (!isUnreleased && !isUsDate) {
continue;
}
@ -138,6 +139,25 @@ export const GET = createSmartRouteHandler({
}).defined(),
}),
handler: async () => {
const isDevelopment = getNodeEnvironment() === "development";
// In development mode, read from local CHANGELOG.md file
if (isDevelopment) {
const changelogPath = path.resolve(process.cwd(), "../../CHANGELOG.md");
const fileExists = await fs.access(changelogPath).then(() => true, () => false);
if (fileExists) {
const content = await fs.readFile(changelogPath, "utf-8");
const entries = parseRootChangelog(content).slice(0, 8);
return {
statusCode: 200,
bodyType: "json",
body: { entries },
} as const;
}
}
const changelogUrl = getEnvVariable("STACK_CHANGELOG_URL", "");
if (!changelogUrl) {

View File

@ -64,6 +64,12 @@ const nextConfig = {
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'raw.githubusercontent.com',
port: '',
pathname: '/**',
},
],
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

View File

@ -15,19 +15,22 @@ 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
* Compare two US date versions in M/D/YY 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})$/);
function isNewerVersion(version1: string, version2: string): boolean {
const parseUsDate = (version: string): Date | null => {
const match = version.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2})$/);
if (!match) return null;
const [, year, month, day] = match;
return new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
const [, month, day, year] = match;
const twoDigitYear = parseInt(year);
// Sliding window: 70-99 → 1970-1999, 00-69 → 2000-2069
const fullYear = twoDigitYear >= 70 ? 1900 + twoDigitYear : 2000 + twoDigitYear;
return new Date(fullYear, parseInt(month) - 1, parseInt(day));
};
const date1 = parseCalVer(version1);
const date2 = parseCalVer(version2);
const date1 = parseUsDate(version1);
const date2 = parseUsDate(version2);
if (!date1 || !date2) {
// Fallback to string comparison if parsing fails
@ -193,7 +196,7 @@ export function StackCompanion({ className }: { className?: string }) {
} else {
const hasNewer = entries.some((entry: ChangelogEntry) => {
if (entry.isUnreleased) return false;
return isNewerCalVer(entry.version, lastSeen);
return isNewerVersion(entry.version, lastSeen);
});
setHasNewVersions(hasNewer);
}
@ -231,7 +234,7 @@ export function StackCompanion({ className }: { className?: string }) {
} else {
const hasNewer = changelogData.some((entry: ChangelogEntry) => {
if (entry.isUnreleased) return false;
return isNewerCalVer(entry.version, lastSeen);
return isNewerVersion(entry.version, lastSeen);
});
setHasNewVersions(hasNewer);
}

View File

@ -2,11 +2,12 @@
import { Button } from '@/components/ui';
import { getPublicEnvVar } from '@/lib/env';
import { CalendarIcon, CaretDownIcon, CaretUpIcon, InfoIcon } from '@phosphor-icons/react';
import { CalendarIcon, CaretDownIcon, CaretUpIcon, InfoIcon, XIcon } 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 { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@ -49,73 +50,73 @@ function markLatestVersionSeen(entries: ApiChangelogEntry[]) {
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}`;
}
// Version is already in US date format (M/D/YY), return as-is
return version;
};
// 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 [previewImage, setPreviewImage] = useState<{ src: string, alt: string } | null>(null);
// Markdown component overrides for changelog rendering
const markdownComponents = useMemo(() => ({
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 }: { src?: string, alt?: string }) => {
if (!src) return null;
return (
<button
type="button"
onClick={() => setPreviewImage({ src, alt: alt || '' })}
className="block w-full cursor-zoom-in focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-lg"
>
<Image
src={src}
alt={alt || ''}
width={800}
height={600}
className="rounded-lg border border-border max-w-full h-auto my-4 transition-opacity hover:transition-none hover:opacity-90"
/>
</button>
);
},
}), []);
const fetchChangelog = useCallback(async (signal?: AbortSignal) => {
try {
@ -172,6 +173,20 @@ export function ChangelogWidget({ isActive, initialData }: ChangelogWidgetProps)
));
};
// Handle Escape key to close the image preview
useEffect(() => {
if (!previewImage) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setPreviewImage(null);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [previewImage]);
if (loading) {
return (
<div className="space-y-4">
@ -197,65 +212,100 @@ export function ChangelogWidget({ isActive, initialData }: ChangelogWidgetProps)
}
return (
<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>
<>
<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 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>
{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>
))}
{error && (
<p className="text-xs text-destructive mt-2">
{error}
</p>
)}
</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">{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>
</div>
{/* Image preview lightbox - rendered via portal to escape container constraints */}
{previewImage && createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm"
onClick={() => setPreviewImage(null)}
role="dialog"
aria-modal="true"
aria-label="Image preview"
>
<button
type="button"
onClick={() => setPreviewImage(null)}
className="absolute top-4 right-4 p-2 rounded-full bg-black/50 text-white transition-colors hover:transition-none hover:bg-black/70"
aria-label="Close preview"
>
<XIcon className="h-6 w-6" />
</button>
<div
className="relative max-w-[90vw] max-h-[90vh]"
onClick={(e) => e.stopPropagation()}
>
<Image
src={previewImage.src}
alt={previewImage.alt}
width={1600}
height={1200}
className="rounded-lg max-w-full max-h-[90vh] w-auto h-auto object-contain"
/>
</div>
</div>,
document.body
)}
</>
);
}