mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Merge remote-tracking branch 'origin/dev' into external-db-sync
This commit is contained in:
commit
b5781a146d
14
CHANGELOG.md
14
CHANGELOG.md
@ -2,28 +2,32 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026.01.23
|
||||
## 1/23/26
|
||||
|
||||
### Payments
|
||||
Introduced a redesigned payments onboarding flow
|
||||

|
||||
|
||||
## 2026.01.21
|
||||
## 1/21/26
|
||||
|
||||
### Payments
|
||||
- Payments page updated with new UI changes
|
||||

|
||||
- Added a new Payments Settings page with an option to temporarily disable all payments
|
||||

|
||||
- Subscription renewal emails are now sent automatically to users
|
||||
- Past payment invoices are now visible on the Account Settings page
|
||||

|
||||
|
||||
### 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 |
@ -2,12 +2,12 @@
|
||||
|
||||
import { Alert, Button, Skeleton, Typography } from "@/components/ui";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@ -16,13 +16,13 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useFromNow } from "@/hooks/use-from-now";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ArrowClockwiseIcon,
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
MagnifyingGlassIcon,
|
||||
SparkleIcon,
|
||||
ArrowClockwiseIcon,
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
MagnifyingGlassIcon,
|
||||
SparkleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
@ -2,11 +2,11 @@
|
||||
|
||||
import { useAdminAppIfExists } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SimpleTooltip } from "@/components/ui/simple-tooltip";
|
||||
@ -14,11 +14,11 @@ import { useDebouncedAction } from "@/hooks/use-debounced-action";
|
||||
import { useFromNow } from "@/hooks/use-from-now";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ArrowClockwiseIcon,
|
||||
CheckCircleIcon,
|
||||
PlayIcon,
|
||||
SpinnerGapIcon,
|
||||
WarningCircleIcon,
|
||||
ArrowClockwiseIcon,
|
||||
CheckCircleIcon,
|
||||
PlayIcon,
|
||||
SpinnerGapIcon,
|
||||
WarningCircleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { memo, useCallback, useMemo, useRef, useState } from "react";
|
||||
|
||||
@ -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