Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8b62012
init changelog route
madster456 Dec 21, 2025
903ea2c
init changelog parse
madster456 Dec 21, 2025
50bd17e
Update for new changelog
madster456 Dec 21, 2025
3b10389
Merge branch 'dev' into dashboard/changelog
madster456 Jan 8, 2026
5aad4e6
changelog in stack-companion
madster456 Jan 8, 2026
13f023a
Merge branch 'dev' into dashboard/changelog
madster456 Jan 8, 2026
ec8d86e
Merge branch 'dev' into dashboard/changelog
madster456 Jan 10, 2026
7d112df
Merge branch 'dev' into dashboard/changelog
madster456 Jan 14, 2026
3971834
cookie parsing and string comparing
madster456 Jan 14, 2026
74d3dfb
update properly typed props
madster456 Jan 14, 2026
0a30feb
Removed unused code
madster456 Jan 14, 2026
096fa26
abort controller, empty array handling, error propagation from parent
madster456 Jan 14, 2026
31e7e76
knosti's notes
madster456 Jan 22, 2026
3c250fc
Merge branch 'dev' into dashboard/changelog
madster456 Jan 27, 2026
5dfed8c
Move changelog fetching to backend
madster456 Jan 27, 2026
c6773ee
Ignore unreleased in changelog
madster456 Jan 27, 2026
030b8c5
Merge branch 'dev' into dashboard/changelog
madster456 Jan 27, 2026
872c3ba
Merge branch 'dev' into dashboard/changelog
madster456 Jan 27, 2026
da92216
Fixed type mistmatch
madster456 Jan 27, 2026
d1e4bd2
Merge branch 'dev' into dashboard/changelog
madster456 Jan 28, 2026
63cca4b
Merge branch 'dev' into dashboard/changelog
madster456 Jan 28, 2026
2cf1d0d
Merge branch 'dev' into dashboard/changelog
madster456 Jan 28, 2026
49f674a
Merge branch 'dev' into dashboard/changelog
madster456 Jan 28, 2026
d69f72f
Merge branch 'dev' into dashboard/changelog
madster456 Jan 28, 2026
c7cf181
Merge branch 'dev' into dashboard/changelog
madster456 Jan 29, 2026
1542480
Merge branch 'dev' into dashboard/changelog
madster456 Jan 29, 2026
ae6b94b
Merge branch 'dev' into dashboard/changelog
madster456 Jan 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/dashboard/.env
Original file line number Diff line number Diff line change
Expand Up @@ -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.
42 changes: 42 additions & 0 deletions apps/dashboard/src/app/api/changelog/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { parseRootChangelog } from "@/lib/changelog";
import { NextResponse } from "next/server";

const REVALIDATE_SECONDS = 60 * 60;

export async function GET() {
const changelogUrl = process.env.STACK_CHANGELOG_URL;

if (!changelogUrl) {
return NextResponse.json({ entries: [] });
}

try {
const response = await fetch(changelogUrl, {
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 },
);
}
}
Comment thread
madster456 marked this conversation as resolved.
Outdated
134 changes: 131 additions & 3 deletions apps/dashboard/src/components/stack-companion.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,50 @@
'use client';

import { Button, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui';
import { ChangelogEntry } from '@/lib/changelog';
Comment thread
madster456 marked this conversation as resolved.
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';
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,
Expand Down Expand Up @@ -73,6 +107,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);
Expand All @@ -82,6 +117,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);
Expand Down Expand Up @@ -125,6 +163,88 @@ 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 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);
}
}
} else {
// If fetch failed, leave changelogData as undefined so widget can try fetching itself
console.error('Failed to fetch changelog data: response not OK');
}
} catch (error) {
console.error('Failed to fetch changelog data:', error);
// Leave changelogData as undefined so widget can try fetching itself
}
Comment thread
madster456 marked this conversation as resolved.
};
Comment thread
madster456 marked this conversation as resolved.
Outdated

runAsynchronously(fetchChangelogData());
}, []);
Comment thread
madster456 marked this conversation as resolved.

// Re-check for new versions when changelog is opened/closed
useEffect(() => {
if (activeItem === 'changelog') {
// When changelog is opened, mark the latest version as seen
if (changelogData && changelogData.length > 0) {
const latestVersion = changelogData[0].version;
document.cookie = `stack-last-seen-changelog-version=${sanitizeCookieValue(latestVersion)}; path=/; max-age=31536000`; // 1 year
setLastSeenVersion(latestVersion);
Comment thread
madster456 marked this conversation as resolved.
Outdated
}
// 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);
Comment thread
madster456 marked this conversation as resolved.
}
} else {
setHasNewVersions(false);
}
}
}, [activeItem, changelogData]);


const openDrawer = useCallback((itemId: string) => {
setActiveItem(itemId);
setIsAnimating(true);
Expand Down Expand Up @@ -304,7 +424,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>
Expand Down Expand Up @@ -338,18 +458,26 @@ 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();
handleItemClick(item.id);
}}
>
<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>
))}
Expand Down
Loading