From 8b620120de9d959d025c30a4d97896e6350579e0 Mon Sep 17 00:00:00 2001 From: Madison Date: Sun, 21 Dec 2025 03:05:36 -0600 Subject: [PATCH 01/12] init changelog route --- apps/dashboard/src/app/api/changelog/route.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 apps/dashboard/src/app/api/changelog/route.ts diff --git a/apps/dashboard/src/app/api/changelog/route.ts b/apps/dashboard/src/app/api/changelog/route.ts new file mode 100644 index 0000000000..90328cc3f9 --- /dev/null +++ b/apps/dashboard/src/app/api/changelog/route.ts @@ -0,0 +1,37 @@ +import { parseRootChangelog } from "@/lib/changelog"; +import { NextResponse } from "next/server"; + +const ROOT_CHANGELOG_URL = "https://raw.githubusercontent.com/stack-auth/stack-auth/965c0d315609e7b1fe184e8ead40e154b5364b8c/CHANGELOG.md"; +const REVALIDATE_SECONDS = 60 * 60; + +export async function GET() { + try { + const response = await fetch(ROOT_CHANGELOG_URL, { + headers: { + "Accept": "text/plain", + "User-Agent": "stack-auth-dashboard-changelog-widget", + }, + next: { + revalidate: REVALIDATE_SECONDS, + }, + }); + + if (!response.ok) { + return NextResponse.json( + { error: "Failed to download changelog" }, + { status: 502 }, + ); + } + + const content = await response.text(); + const entries = parseRootChangelog(content).slice(0, 8); + + return NextResponse.json({ entries }); + } catch (error) { + console.error("Failed to fetch remote changelog", error); + return NextResponse.json( + { error: "Failed to fetch changelog" }, + { status: 500 }, + ); + } +} From 903ea2c352ef4cd5845b82ab327df08bdb0b3502 Mon Sep 17 00:00:00 2001 From: Madison Date: Sun, 21 Dec 2025 03:06:15 -0600 Subject: [PATCH 02/12] init changelog parse --- apps/dashboard/src/lib/changelog.ts | 112 ++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 apps/dashboard/src/lib/changelog.ts diff --git a/apps/dashboard/src/lib/changelog.ts b/apps/dashboard/src/lib/changelog.ts new file mode 100644 index 0000000000..bb55d3a6e3 --- /dev/null +++ b/apps/dashboard/src/lib/changelog.ts @@ -0,0 +1,112 @@ +export type ChangeType = "major" | "minor" | "patch"; + +export type ChangelogEntry = { + version: string, + type: ChangeType, + markdown: string, + bulletCount: number, + bullets: { tags: string[] }[], + 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 }; +} + +export 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[] = []; + const bulletMeta: { tags: string[] }[] = []; + + for (const line of lines) { + if (line.trim().startsWith("- ")) { + const { text, tags } = parseTaggedBullet(line); + bulletMeta.push({ tags }); + processedLines.push(text ? `- ${text}` : "-"); + } else { + processedLines.push(line); + } + } + + const normalizedMarkdown = processedLines.join("\n").trim(); + const bulletCount = bulletMeta.length; + + entries.push({ + version, + type, + markdown: normalizedMarkdown, + bulletCount, + bullets: bulletMeta, + isUnreleased, + releasedAt, + }); + } + + return entries; +} + +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, + }; +} From 50bd17ea4e4d1e5c596835db34c1d6e41fbd08b4 Mon Sep 17 00:00:00 2001 From: Madison Date: Sun, 21 Dec 2025 03:06:30 -0600 Subject: [PATCH 03/12] Update for new changelog --- .../src/components/stack-companion.tsx | 75 ++- .../stack-companion/changelog-widget.tsx | 513 +++++++++--------- 2 files changed, 342 insertions(+), 246 deletions(-) diff --git a/apps/dashboard/src/components/stack-companion.tsx b/apps/dashboard/src/components/stack-companion.tsx index 48eb9f3b30..0698d446ae 100644 --- a/apps/dashboard/src/components/stack-companion.tsx +++ b/apps/dashboard/src/components/stack-companion.tsx @@ -1,9 +1,11 @@ 'use client'; +import { Button, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'; +import { ChangelogEntry } from '@/lib/changelog'; import { cn } from '@/lib/utils'; import { checkVersion, VersionCheckResult } from '@/lib/version-check'; -import { Button, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'; -import { BookOpen, HelpCircle, Lightbulb, TimerReset, X } from 'lucide-react'; +import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises'; +import { Bell, BookOpen, HelpCircle, Lightbulb, TimerReset, X } from 'lucide-react'; import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; import packageJson from '../../package.json'; import { FeedbackForm } from './feedback-form'; @@ -73,6 +75,7 @@ export function useStackCompanion() { return useContext(StackCompanionContext); } + export function StackCompanion({ className }: { className?: string }) { const [activeItem, setActiveItem] = useState(null); const [mounted, setMounted] = useState(false); @@ -82,6 +85,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([]); + const [hasNewVersions, setHasNewVersions] = useState(false); + const [lastSeenVersion, setLastSeenVersion] = useState(''); const startXRef = useRef(0); const startWidthRef = useRef(0); @@ -125,6 +131,62 @@ export function StackCompanion({ className }: { className?: string }) { return cleanup; }, []); + // Fetch changelog data on mount and check for new versions + useEffect(() => { + const fetchChangelogData = async () => { + try { + const response = await fetch('/api/changelog'); + if (response.ok) { + const payload = await response.json(); + const entries = payload.entries || []; + setChangelogData(entries); + + // Check for new versions + const lastSeen = document.cookie + .split('; ') + .find(row => row.startsWith('stack-last-seen-changelog-version=')) + ?.split('=')[1] || ''; + + setLastSeenVersion(lastSeen); + + if (entries.length > 0 && lastSeen) { + const hasNewer = entries.some((entry: ChangelogEntry) => { + if (entry.isUnreleased) return false; + return entry.version > lastSeen; + }); + setHasNewVersions(hasNewer); + } + } + } catch (error) { + console.error('Failed to fetch changelog data:', error); + } + }; + + runAsynchronously(fetchChangelogData()); + }, []); + + // Re-check for new versions when changelog is opened/closed + useEffect(() => { + if (activeItem === 'changelog' || activeItem === null) { + // Re-check versions when opening or closing changelog + const lastSeen = document.cookie + .split('; ') + .find(row => row.startsWith('stack-last-seen-changelog-version=')) + ?.split('=')[1] || ''; + + if (changelogData.length > 0 && lastSeen) { + const hasNewer = changelogData.some((entry: ChangelogEntry) => { + if (entry.isUnreleased) return false; + return entry.version > lastSeen; + }); + setHasNewVersions(hasNewer); + } else { + setHasNewVersions(false); + } + } + }, [activeItem, changelogData]); + + const openDrawer = useCallback((itemId: string) => { setActiveItem(itemId); setIsAnimating(true); @@ -304,7 +366,7 @@ export function StackCompanion({ className }: { className?: string }) {
{activeItem === 'docs' && } {activeItem === 'feedback' && } - {activeItem === 'changelog' && } + {activeItem === 'changelog' && } {activeItem === 'support' && }
@@ -346,10 +408,15 @@ export function StackCompanion({ className }: { className?: string }) { }} > + {item.id === 'changelog' && hasNewVersions && ( +
+ +
+ )} - {item.label} + {item.id === 'changelog' && hasNewVersions ? `${item.label} (New updates available!)` : item.label} ))} diff --git a/apps/dashboard/src/components/stack-companion/changelog-widget.tsx b/apps/dashboard/src/components/stack-companion/changelog-widget.tsx index 5e2c57332f..0540f54116 100644 --- a/apps/dashboard/src/components/stack-companion/changelog-widget.tsx +++ b/apps/dashboard/src/components/stack-companion/changelog-widget.tsx @@ -1,285 +1,314 @@ 'use client'; -import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises'; import { Button } from '@/components/ui'; -import { Calendar, ChevronDown, ChevronUp } from 'lucide-react'; +import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises'; +import { Calendar, ChevronDown, ChevronUp, Info } from 'lucide-react'; 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, +const TYPE_LABEL = new Map([ + ['major', 'Major release'], + ['minor', 'Minor update'], + ['patch', 'Patch'], +]); + +const TYPE_BADGE_CLASS = new Map([ + ['major', 'bg-rose-100 text-rose-800 dark:bg-rose-500/20 dark:text-rose-200'], + ['minor', 'bg-blue-100 text-blue-800 dark:bg-blue-500/20 dark:text-blue-200'], + ['patch', 'bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-100'], +]); + +const COLLAPSE_THRESHOLD = 220; + +const shouldCollapseContent = (markdown: string) => { + const textContent = markdown + .replace(/`([^`]+)`/g, '$1') + .replace(/\[(.*?)\]\((.*?)\)/g, '$1') + .replace(/[#>*_\-]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + return textContent.length > COLLAPSE_THRESHOLD; }; -export function ChangelogWidget({ isActive }: ChangelogWidgetProps) { - const [changelogs, setChangelogs] = useState([]); - const [loading, setLoading] = useState(true); +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; +}; - const toggleExpanded = (id: string) => { - setChangelogs(prev => prev.map(changelog => - changelog.id === id - ? { ...changelog, expanded: !changelog.expanded } - : changelog - )); - }; +const NoteBlockquote = ({ children, ...props }: any) => { + return ( +
+
+ +
+ {children} +
+
+
+ ); +}; - // 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 - }; +const ChangelogListItem = ({ children, ...props }: any) => { + return ( +
  • + {children} +
  • + ); +}; - useEffect(() => { - if (!isActive) return; +const ChangelogImage = ({ src, alt, ...props }: any) => { + return ( + {alt} + ); +}; - 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); - }; - } +export function ChangelogWidget({ isActive, initialData }: ChangelogWidgetProps) { + const [changelog, setChangelog] = useState([]); + const [loading, setLoading] = useState(!initialData); + const [error, setError] = useState(null); + const hasFetchedRef = useRef(false); - // 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", - }); + const fetchChangelog = useCallback(async (signal?: AbortSignal) => { + try { + setLoading(true); + setError(null); - // 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', - }, - }); + // Fake entries for UI testing - replace with real fetch later + const fakeEntries: ApiChangelogEntry[] = [ + { + version: "2025.12.19", + type: "patch", // Default type since we don't use major/minor/patch anymore + markdown: `- Introduces new changelog and deprecates all older changelogs +- Moved away from semantic versioning in favor of CalVer - 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 { +> **Note:** All older changelogs are deprecated and have been removed. The source of truth is this single changelog file. +> +> Going forward, all changes should be documented in this file only.`, + bulletCount: 2, + releasedAt: "2025-12-19", + }, + { + version: "2025.12.18", + type: "patch", + markdown: `- Added new authentication methods for better security +- Improved error handling in API responses +- Updated documentation with new examples + +![New authentication flow](storeDesc-auth-1.png) + +> **Security**: Enhanced encryption for sensitive data storage.`, + bulletCount: 3, + releasedAt: "2025-12-18", + }, + { + version: "2025.12.17", + type: "patch", + markdown: `- Fixed memory leak in session management +- Resolved issue with OAuth callback redirects +- Corrected timezone handling in audit logs`, + bulletCount: 3, + releasedAt: "2025-12-17", + }, + { + version: "2025.12.16", + type: "patch", + markdown: `- Added webhook support for user events +- Implemented bulk user import functionality +- New API endpoints for team management`, + bulletCount: 3, + releasedAt: "2025-12-16", + }, + { + version: "2025.12.15", + type: "patch", + markdown: `- Fixed issue with password reset emails not sending +- Corrected validation for phone number fields +- Resolved database connection timeout issues`, + bulletCount: 3, + releasedAt: "2025-12-15", + }, + ]; + + setChangelog(fakeEntries.map((entry, index) => ({ + ...entry, + id: `${entry.version}-${entry.releasedAt ?? 'unreleased'}`, + expanded: index === 0, // Only expand the first (latest) entry + }))); + } catch (cause) { + if (signal?.aborted) { + return; + } + console.error('Failed to fetch changelog', 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) { + // Use provided initial data + setChangelog(initialData.map((entry, index) => ({ + ...entry, + id: `${entry.version}-${entry.releasedAt ?? 'unreleased'}`, + expanded: index === 0, // Only expand the first (latest) entry + }))); + setLoading(false); + + // Update last seen version when changelog is opened + if (initialData.length > 0) { + const latestVersion = initialData[0].version; + document.cookie = `stack-last-seen-changelog-version=${latestVersion}; path=/; max-age=31536000`; // 1 year + } + } else { + // Fallback to fetching if no initial data provided + runAsynchronously(fetchChangelog()); + } + }, [fetchChangelog, isActive, initialData]); - runAsynchronously(fetchChangelogs()); - }, [isActive]); + const toggleExpanded = (id: string) => { + setChangelog((prev) => prev.map((entry) => + entry.id === id ? { ...entry, expanded: !entry.expanded } : entry, + )); + }; if (loading) { return ( - <> -