|
2 | 2 |
|
3 | 3 | import { Button } from '@/components/ui'; |
4 | 4 | import { getPublicEnvVar } from '@/lib/env'; |
5 | | -import { CalendarIcon, CaretDownIcon, CaretUpIcon, InfoIcon } from '@phosphor-icons/react'; |
| 5 | +import { CalendarIcon, CaretDownIcon, CaretUpIcon, InfoIcon, XIcon } from '@phosphor-icons/react'; |
6 | 6 | import { captureError } from '@stackframe/stack-shared/dist/utils/errors'; |
7 | 7 | import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises'; |
8 | 8 | import Image from 'next/image'; |
9 | | -import { useCallback, useEffect, useRef, useState } from 'react'; |
| 9 | +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; |
| 10 | +import { createPortal } from 'react-dom'; |
10 | 11 | import ReactMarkdown from 'react-markdown'; |
11 | 12 | import remarkGfm from 'remark-gfm'; |
12 | 13 |
|
@@ -49,73 +50,73 @@ function markLatestVersionSeen(entries: ApiChangelogEntry[]) { |
49 | 50 |
|
50 | 51 |
|
51 | 52 | const formatVersion = (version: string) => { |
52 | | - // Convert YYYY.MM.DD to YY.MM.DD format for display |
53 | | - const calVerMatch = version.match(/^(\d{4})\.(\d{2})\.(\d{2})$/); |
54 | | - if (calVerMatch) { |
55 | | - const [, year, month, day] = calVerMatch; |
56 | | - const shortYear = year.slice(-2); // Get last 2 digits |
57 | | - return `${shortYear}.${month}.${day}`; |
58 | | - } |
| 53 | + // Version is already in US date format (M/D/YY), return as-is |
59 | 54 | return version; |
60 | 55 | }; |
61 | 56 |
|
62 | | -// Markdown component overrides for changelog rendering |
63 | | -const markdownComponents = { |
64 | | - h3: ({ children }: { children?: React.ReactNode }) => ( |
65 | | - <h3 className="text-sm font-semibold text-foreground mt-4 mb-2 first:mt-0"> |
66 | | - {children} |
67 | | - </h3> |
68 | | - ), |
69 | | - p: ({ children }: { children?: React.ReactNode }) => ( |
70 | | - <p className="text-muted-foreground leading-relaxed mb-2 last:mb-0"> |
71 | | - {children} |
72 | | - </p> |
73 | | - ), |
74 | | - ul: ({ children }: { children?: React.ReactNode }) => ( |
75 | | - <ul className="list-disc list-outside ml-4 space-y-1 text-muted-foreground"> |
76 | | - {children} |
77 | | - </ul> |
78 | | - ), |
79 | | - li: ({ children }: { children?: React.ReactNode }) => ( |
80 | | - <li className="leading-relaxed"> |
81 | | - {children} |
82 | | - </li> |
83 | | - ), |
84 | | - code: ({ children }: { children?: React.ReactNode }) => ( |
85 | | - <code className="px-1.5 py-0.5 rounded bg-muted text-foreground text-xs font-mono"> |
86 | | - {children} |
87 | | - </code> |
88 | | - ), |
89 | | - blockquote: ({ children }: { children?: React.ReactNode }) => ( |
90 | | - <div className="bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 p-3 my-3 rounded-md"> |
91 | | - <div className="flex items-start gap-2"> |
92 | | - <InfoIcon className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" /> |
93 | | - <div className="text-sm text-blue-800 dark:text-blue-200 leading-relaxed [&>p]:mb-0"> |
94 | | - {children} |
95 | | - </div> |
96 | | - </div> |
97 | | - </div> |
98 | | - ), |
99 | | - img: ({ src, alt, title }: { src?: string, alt?: string, title?: string }) => { |
100 | | - if (!src) return null; |
101 | | - return ( |
102 | | - <Image |
103 | | - src={src} |
104 | | - alt={alt || ''} |
105 | | - title={title} |
106 | | - width={800} |
107 | | - height={600} |
108 | | - className="rounded-lg border border-border max-w-full h-auto my-4" |
109 | | - /> |
110 | | - ); |
111 | | - }, |
112 | | -}; |
113 | | - |
114 | 57 | export function ChangelogWidget({ isActive, initialData }: ChangelogWidgetProps) { |
115 | 58 | const [changelog, setChangelog] = useState<ChangelogItem[]>([]); |
116 | 59 | const [loading, setLoading] = useState(!initialData); |
117 | 60 | const [error, setError] = useState<string | null>(null); |
118 | 61 | const hasFetchedRef = useRef(false); |
| 62 | + const [previewImage, setPreviewImage] = useState<{ src: string, alt: string } | null>(null); |
| 63 | + |
| 64 | + // Markdown component overrides for changelog rendering |
| 65 | + const markdownComponents = useMemo(() => ({ |
| 66 | + h3: ({ children }: { children?: React.ReactNode }) => ( |
| 67 | + <h3 className="text-sm font-semibold text-foreground mt-4 mb-2 first:mt-0"> |
| 68 | + {children} |
| 69 | + </h3> |
| 70 | + ), |
| 71 | + p: ({ children }: { children?: React.ReactNode }) => ( |
| 72 | + <p className="text-muted-foreground leading-relaxed mb-2 last:mb-0"> |
| 73 | + {children} |
| 74 | + </p> |
| 75 | + ), |
| 76 | + ul: ({ children }: { children?: React.ReactNode }) => ( |
| 77 | + <ul className="list-disc list-outside ml-4 space-y-1 text-muted-foreground"> |
| 78 | + {children} |
| 79 | + </ul> |
| 80 | + ), |
| 81 | + li: ({ children }: { children?: React.ReactNode }) => ( |
| 82 | + <li className="leading-relaxed"> |
| 83 | + {children} |
| 84 | + </li> |
| 85 | + ), |
| 86 | + code: ({ children }: { children?: React.ReactNode }) => ( |
| 87 | + <code className="px-1.5 py-0.5 rounded bg-muted text-foreground text-xs font-mono"> |
| 88 | + {children} |
| 89 | + </code> |
| 90 | + ), |
| 91 | + blockquote: ({ children }: { children?: React.ReactNode }) => ( |
| 92 | + <div className="bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 p-3 my-3 rounded-md"> |
| 93 | + <div className="flex items-start gap-2"> |
| 94 | + <InfoIcon className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" /> |
| 95 | + <div className="text-sm text-blue-800 dark:text-blue-200 leading-relaxed [&>p]:mb-0"> |
| 96 | + {children} |
| 97 | + </div> |
| 98 | + </div> |
| 99 | + </div> |
| 100 | + ), |
| 101 | + img: ({ src, alt }: { src?: string, alt?: string }) => { |
| 102 | + if (!src) return null; |
| 103 | + return ( |
| 104 | + <button |
| 105 | + type="button" |
| 106 | + onClick={() => setPreviewImage({ src, alt: alt || '' })} |
| 107 | + className="block w-full cursor-zoom-in focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-lg" |
| 108 | + > |
| 109 | + <Image |
| 110 | + src={src} |
| 111 | + alt={alt || ''} |
| 112 | + width={800} |
| 113 | + height={600} |
| 114 | + className="rounded-lg border border-border max-w-full h-auto my-4 transition-opacity hover:opacity-90" |
| 115 | + /> |
| 116 | + </button> |
| 117 | + ); |
| 118 | + }, |
| 119 | + }), []); |
119 | 120 |
|
120 | 121 | const fetchChangelog = useCallback(async (signal?: AbortSignal) => { |
121 | 122 | try { |
@@ -197,65 +198,101 @@ export function ChangelogWidget({ isActive, initialData }: ChangelogWidgetProps) |
197 | 198 | } |
198 | 199 |
|
199 | 200 | return ( |
200 | | - <div className="space-y-4"> |
201 | | - <div className="bg-muted/30 rounded-lg p-4"> |
202 | | - <div className="flex items-center justify-between"> |
203 | | - <div> |
204 | | - <h3 className="text-sm font-semibold">Stack Auth releases</h3> |
205 | | - </div> |
206 | | - </div> |
207 | | - {error && ( |
208 | | - <p className="text-xs text-destructive mt-2"> |
209 | | - {error} |
210 | | - </p> |
211 | | - )} |
212 | | - </div> |
213 | | - |
| 201 | + <> |
214 | 202 | <div className="space-y-4"> |
215 | | - {changelog.length === 0 && !error && ( |
216 | | - <div className="bg-muted/30 rounded-lg p-4 text-center"> |
217 | | - <CalendarIcon className="h-8 w-8 mx-auto mb-2 text-muted-foreground" /> |
218 | | - <p className="text-xs text-muted-foreground font-medium"> |
219 | | - No changelog entries found |
220 | | - </p> |
| 203 | + <div className="bg-muted/30 rounded-lg p-4"> |
| 204 | + <div className="flex items-center justify-between"> |
| 205 | + <div> |
| 206 | + <h3 className="text-sm font-semibold">Stack Auth releases</h3> |
| 207 | + </div> |
221 | 208 | </div> |
222 | | - )} |
| 209 | + {error && ( |
| 210 | + <p className="text-xs text-destructive mt-2"> |
| 211 | + {error} |
| 212 | + </p> |
| 213 | + )} |
| 214 | + </div> |
223 | 215 |
|
224 | | - {changelog.map((entry) => ( |
225 | | - <div key={entry.id} className="bg-card rounded-lg border border-border"> |
226 | | - <div className="px-4 py-4 flex items-center justify-between"> |
227 | | - <div className="flex items-center gap-4"> |
228 | | - <h4 className="text-base font-semibold">v{formatVersion(entry.version)}</h4> |
229 | | - </div> |
230 | | - <Button |
231 | | - variant="ghost" |
232 | | - size="sm" |
233 | | - className="h-7 w-7 p-0" |
234 | | - onClick={() => toggleExpanded(entry.id)} |
235 | | - > |
236 | | - {entry.expanded ? ( |
237 | | - <CaretUpIcon className="h-3 w-3" /> |
238 | | - ) : ( |
239 | | - <CaretDownIcon className="h-3 w-3" /> |
240 | | - )} |
241 | | - </Button> |
| 216 | + <div className="space-y-4"> |
| 217 | + {changelog.length === 0 && !error && ( |
| 218 | + <div className="bg-muted/30 rounded-lg p-4 text-center"> |
| 219 | + <CalendarIcon className="h-8 w-8 mx-auto mb-2 text-muted-foreground" /> |
| 220 | + <p className="text-xs text-muted-foreground font-medium"> |
| 221 | + No changelog entries found |
| 222 | + </p> |
242 | 223 | </div> |
| 224 | + )} |
243 | 225 |
|
244 | | - {entry.expanded && ( |
245 | | - <div className="px-4 pb-4"> |
246 | | - <div className="text-sm leading-relaxed space-y-3"> |
247 | | - <ReactMarkdown |
248 | | - remarkPlugins={[remarkGfm]} |
249 | | - components={markdownComponents} |
250 | | - > |
251 | | - {entry.markdown} |
252 | | - </ReactMarkdown> |
| 226 | + {changelog.map((entry) => ( |
| 227 | + <div key={entry.id} className="bg-card rounded-lg border border-border"> |
| 228 | + <div className="px-4 py-4 flex items-center justify-between"> |
| 229 | + <div className="flex items-center gap-4"> |
| 230 | + <h4 className="text-base font-semibold">{formatVersion(entry.version)}</h4> |
253 | 231 | </div> |
| 232 | + <Button |
| 233 | + variant="ghost" |
| 234 | + size="sm" |
| 235 | + className="h-7 w-7 p-0" |
| 236 | + onClick={() => toggleExpanded(entry.id)} |
| 237 | + > |
| 238 | + {entry.expanded ? ( |
| 239 | + <CaretUpIcon className="h-3 w-3" /> |
| 240 | + ) : ( |
| 241 | + <CaretDownIcon className="h-3 w-3" /> |
| 242 | + )} |
| 243 | + </Button> |
254 | 244 | </div> |
255 | | - )} |
256 | | - </div> |
257 | | - ))} |
| 245 | + |
| 246 | + {entry.expanded && ( |
| 247 | + <div className="px-4 pb-4"> |
| 248 | + <div className="text-sm leading-relaxed space-y-3"> |
| 249 | + <ReactMarkdown |
| 250 | + remarkPlugins={[remarkGfm]} |
| 251 | + components={markdownComponents} |
| 252 | + > |
| 253 | + {entry.markdown} |
| 254 | + </ReactMarkdown> |
| 255 | + </div> |
| 256 | + </div> |
| 257 | + )} |
| 258 | + </div> |
| 259 | + ))} |
| 260 | + </div> |
258 | 261 | </div> |
259 | | - </div> |
| 262 | + |
| 263 | + {/* Image preview lightbox - rendered via portal to escape container constraints */} |
| 264 | + {previewImage && createPortal( |
| 265 | + <div |
| 266 | + className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm" |
| 267 | + onClick={() => setPreviewImage(null)} |
| 268 | + onKeyDown={(e) => e.key === 'Escape' && setPreviewImage(null)} |
| 269 | + role="dialog" |
| 270 | + aria-modal="true" |
| 271 | + aria-label="Image preview" |
| 272 | + > |
| 273 | + <button |
| 274 | + type="button" |
| 275 | + onClick={() => setPreviewImage(null)} |
| 276 | + className="absolute top-4 right-4 p-2 rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors" |
| 277 | + aria-label="Close preview" |
| 278 | + > |
| 279 | + <XIcon className="h-6 w-6" /> |
| 280 | + </button> |
| 281 | + <div |
| 282 | + className="relative max-w-[90vw] max-h-[90vh]" |
| 283 | + onClick={(e) => e.stopPropagation()} |
| 284 | + > |
| 285 | + <Image |
| 286 | + src={previewImage.src} |
| 287 | + alt={previewImage.alt} |
| 288 | + width={1600} |
| 289 | + height={1200} |
| 290 | + className="rounded-lg max-w-full max-h-[90vh] w-auto h-auto object-contain" |
| 291 | + /> |
| 292 | + </div> |
| 293 | + </div>, |
| 294 | + document.body |
| 295 | + )} |
| 296 | + </> |
260 | 297 | ); |
261 | 298 | } |
0 commit comments