mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
[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:
parent
56246fcdf6
commit
231b49308d
14
CHANGELOG.md
14
CHANGELOG.md
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -64,6 +64,12 @@ const nextConfig = {
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'raw.githubusercontent.com',
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
BIN
apps/dashboard/public/changelog/account-settings-invoices.png
Normal file
BIN
apps/dashboard/public/changelog/account-settings-invoices.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
BIN
apps/dashboard/public/changelog/payments-create-product.png
Normal file
BIN
apps/dashboard/public/changelog/payments-create-product.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
BIN
apps/dashboard/public/changelog/payments-onboarding.png
Normal file
BIN
apps/dashboard/public/changelog/payments-onboarding.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 208 KiB |
BIN
apps/dashboard/public/changelog/payments-settings-1.png
Normal file
BIN
apps/dashboard/public/changelog/payments-settings-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 345 KiB |
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user