Merge pull request #1322 from PicPeak/fix/canvas-lightbox-only

fix(gallery): keep canvas rendering in the lightbox, render tiles as <img>
This commit is contained in:
Paul Nothaft
2026-09-07 09:19:58 +02:00
committed by GitHub
20 changed files with 68 additions and 81 deletions
@@ -22,9 +22,7 @@ interface GalleryFolderTilesProps {
* so a gallery configured for canvas rendering or maximum protection must not
* get an ordinary blob-backed <img> here just because it is a cover.
*/
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
allowDownloads?: boolean;
/**
* Compact chip row instead of cover cards. Used by the full-bleed layouts
@@ -40,8 +38,6 @@ export const GalleryFolderTiles: React.FC<GalleryFolderTilesProps> = ({
onOpen,
compact = false,
slug,
protectionLevel,
useCanvasRendering,
}) => {
const { t } = useTranslation();
@@ -95,10 +91,6 @@ export const GalleryFolderTiles: React.FC<GalleryFolderTilesProps> = ({
isGallery
slug={slug}
// Same rule as every other gallery image path: maximum
// protection implies canvas rendering even when the separate
// toggle is off (its default), otherwise a cover silently
// falls back to a blob-backed <img>.
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
@@ -1311,9 +1311,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
onOpen={openFolderBySlug}
compact={compact}
slug={slug}
protectionLevel={protectionLevel}
useEnhancedProtection={protectionLevel !== 'basic'}
useCanvasRendering={useCanvasRendering}
allowDownloads={allowDownloads}
/>
);
@@ -23,9 +23,7 @@ interface HeroHeaderProps {
heroLogoPosition?: 'top' | 'center' | 'bottom';
dividerStyle?: HeroDividerStyle;
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
onScrollToContent?: () => void;
// Hero image anchor position (#162) keyword or "X% Y%" focal point
heroImageAnchor?: string;
@@ -43,8 +41,6 @@ export const HeroHeader: React.FC<HeroHeaderProps> = ({
heroLogoSize = 'medium',
heroLogoPosition = 'top',
dividerStyle = 'wave',
protectionLevel = 'standard',
useCanvasRendering = false,
onScrollToContent,
heroImageAnchor = 'center'
}) => {
@@ -142,7 +138,6 @@ export const HeroHeader: React.FC<HeroHeaderProps> = ({
style={{ objectPosition: heroImageAnchor }}
isGallery={true}
slug={slug}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
/>
{/* Overlay */}
@@ -196,7 +196,6 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
slug={slug}
feedbackEnabled={feedbackEnabled}
/>
@@ -214,6 +213,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
disableRightClick={disableRightClick}
enableDevtoolsProtection={enableDevtoolsProtection}
/>
@@ -231,7 +231,6 @@ interface PhotoThumbnailProps {
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
slug: string; // Add slug as required prop
feedbackEnabled?: boolean;
}
@@ -244,7 +243,6 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
onDownload,
allowDownloads = true,
protectionLevel = 'standard',
useCanvasRendering = false,
slug,
feedbackEnabled = false
}) => {
@@ -268,7 +266,6 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
loading="lazy"
isGallery={true}
slug={slug}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
onProtectionViolation={(violationType) => {
// Track analytics
if (typeof window !== 'undefined' && (window as any).umami) {
@@ -361,9 +361,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
heroLogoPosition={heroLogoPosition}
dividerStyle={heroDividerStyle}
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
heroImageAnchor={heroImageAnchor}
/>
)}
@@ -0,0 +1,56 @@
/**
* Canvas rendering is a lightbox concern, not a tile concern.
*
* A canvas pins a backing store of naturalWidth × naturalHeight × 4 bytes
* that the browser is not allowed to evict, and iOS Safari has a hard
* budget for canvas memory that fails silently when exceeded blank
* tiles, no error. A gallery is hundreds of tiles and one lightbox image,
* so the tiles render <img> whatever the protection level says, and the
* lightbox keeps the per-event toggle and the `maximum` implication.
*
* Source-level pin: nothing under components/gallery except PhotoLightbox
* may hand `useCanvasRendering` to AuthenticatedImage.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
const root = path.resolve(__dirname, '..');
function walk(dir: string): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const file = path.join(dir, entry.name);
if (entry.isDirectory()) return entry.name === '__tests__' ? [] : walk(file);
return file.endsWith('.tsx') ? [file] : [];
});
}
describe('canvas rendering stays in the lightbox', () => {
const files = walk(root);
const lightbox = path.join(root, 'PhotoLightbox.tsx');
/** The JSX props of every <AuthenticatedImage> in a file, plus every
* `imageProps={{ ... }}` object a layout hands to PhotoCard to spread in. */
const imageProps = (source: string) => [
...source.split('<AuthenticatedImage').slice(1).map((chunk) => chunk.split('/>')[0]),
...source.split('imageProps={{').slice(1).map((chunk) => chunk.split('}}')[0]),
];
it('only PhotoLightbox passes useCanvasRendering to AuthenticatedImage', () => {
const offenders = files.filter((file) => file !== lightbox
&& imageProps(fs.readFileSync(file, 'utf8')).some((props) => props.includes('useCanvasRendering')));
expect(offenders.map((f) => path.relative(root, f))).toEqual([]);
// The pin has teeth: the lightbox itself is caught by the same probe.
expect(imageProps(fs.readFileSync(lightbox, 'utf8')).some((props) => props.includes('useCanvasRendering'))).toBe(true);
});
it('only PhotoLightbox turns canvas on for protection level maximum', () => {
const offenders = files.filter((file) => file !== lightbox
&& /useCanvasRendering[^\n]*protectionLevel === 'maximum'/.test(fs.readFileSync(file, 'utf8')));
expect(offenders.map((f) => path.relative(root, f))).toEqual([]);
});
it('the lightbox still does both', () => {
const source = fs.readFileSync(lightbox, 'utf8');
expect(source).toMatch(/useCanvasRendering=\{useCanvasRendering \|\| protectionLevel === 'maximum'\}/);
});
});
@@ -47,6 +47,7 @@ export interface BaseGalleryLayoutProps {
onPickResolution?: (photoIds: number[]) => void;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
/** Canvas rendering applies to the lightbox only; tiles always render <img>. */
useCanvasRendering?: boolean;
feedbackEnabled?: boolean;
feedbackOptions?: {
@@ -46,9 +46,7 @@ interface PhotoCardProps {
isLiked: boolean;
slug: string;
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
feedbackEnabled?: boolean;
// #506: track the per-event "allow likes" toggle so the per-photo
// Like button respects it. `feedbackEnabled` alone isn't enough —
@@ -68,8 +66,6 @@ const PhotoCard: React.FC<PhotoCardProps> = ({
isSelectionMode,
isLiked,
slug,
protectionLevel = 'standard',
useCanvasRendering = false,
feedbackEnabled = false,
allowLikes = false,
index
@@ -122,7 +118,6 @@ const PhotoCard: React.FC<PhotoCardProps> = ({
loading="lazy"
isGallery={true}
slug={slug}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
/>
{/* Colour label (#1044) — same badge every layout uses. */}
@@ -208,9 +203,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
allowDownloads = true,
downloadChoices,
onPickResolution,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
feedbackEnabled = false,
feedbackOptions,
heroPhotoOverride,
@@ -629,9 +622,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
isLiked={likedPhotoIds.has(originalPhoto.id)}
slug={slug}
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
feedbackEnabled={feedbackEnabled}
allowLikes={!!feedbackOptions?.allowLikes}
index={photoIndex}
@@ -255,9 +255,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
photo={heroPhoto}
slug={slug}
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
/>
{/* Main Content - Scenes */}
@@ -281,9 +279,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
onPhotoClick={handleOpenLightbox}
slug={slug}
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
/>
) : (
<div id={`gallery-${scene.id}`} className="story-gallery-grid">
@@ -298,9 +294,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
slug={slug}
galleryId={`gallery-${scene.id}`}
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
// Mark first photo in each grid as featured
featured={index === 0 && scene.photos.length > 4}
/>
@@ -18,9 +18,7 @@ interface GridPhotoProps {
animationType?: string;
allowDownloads?: boolean;
slug?: string;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
feedbackEnabled?: boolean;
feedbackOptions?: {
allowLikes?: boolean;
@@ -47,8 +45,6 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
animationType = 'fade',
allowDownloads = true,
slug,
protectionLevel = 'standard',
useCanvasRendering = false,
feedbackEnabled = false,
feedbackOptions,
savedIdentity,
@@ -130,7 +126,6 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
loading: 'lazy',
isGallery: true,
slug,
useCanvasRendering: useCanvasRendering || protectionLevel === 'maximum',
onProtectionViolation: (violationType: string) => {
console.warn(`Protection violation on grid photo ${photo.id}: ${violationType}`);
},
@@ -200,9 +195,7 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
isSelectionMode = false,
onPhotoSelect,
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
feedbackEnabled = false,
feedbackOptions,
isClient = false,
@@ -255,9 +248,7 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
animationType={animation}
allowDownloads={allowDownloads}
slug={slug}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
savedIdentity={savedIdentity}
@@ -40,9 +40,7 @@ interface JustifiedPhotoProps {
animationType?: string;
allowDownloads?: boolean;
slug?: string;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
feedbackEnabled?: boolean;
feedbackOptions?: {
allowLikes?: boolean;
@@ -69,8 +67,6 @@ const JustifiedPhoto: React.FC<JustifiedPhotoProps> = ({
animationType = 'fade',
allowDownloads = true,
slug,
protectionLevel = 'standard',
useCanvasRendering = false,
feedbackEnabled = false,
feedbackOptions,
savedIdentity,
@@ -133,7 +129,6 @@ const JustifiedPhoto: React.FC<JustifiedPhotoProps> = ({
loading: 'lazy',
isGallery: true,
slug,
useCanvasRendering: useCanvasRendering || protectionLevel === 'maximum',
onProtectionViolation: (violationType: string) => {
console.warn(`Protection violation on justified photo ${photo.id}: ${violationType}`);
},
@@ -214,9 +209,7 @@ export const JustifiedGalleryLayout: React.FC<JustifiedGalleryLayoutProps> = ({
isSelectionMode = false,
onPhotoSelect,
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
feedbackEnabled = false,
feedbackOptions,
// Hero props
@@ -399,7 +392,6 @@ export const JustifiedGalleryLayout: React.FC<JustifiedGalleryLayoutProps> = ({
className="w-full h-full object-cover"
isGallery={true}
slug={slug}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
/>
{/* Overlay */}
@@ -523,9 +515,7 @@ export const JustifiedGalleryLayout: React.FC<JustifiedGalleryLayoutProps> = ({
animationType={animation}
allowDownloads={allowDownloads}
slug={slug}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
savedIdentity={savedIdentity}
@@ -14,9 +14,7 @@ interface StoryCarouselProps {
slug: string;
id: string;
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
}
export const StoryCarousel: React.FC<StoryCarouselProps> = ({
@@ -27,9 +25,7 @@ export const StoryCarousel: React.FC<StoryCarouselProps> = ({
slug,
id,
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false
}) => {
return (
<div id={id} className="story-carousel">
@@ -53,9 +49,7 @@ export const StoryCarousel: React.FC<StoryCarouselProps> = ({
slug={slug}
galleryId={id}
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
/>
</div>
</SwiperSlide>
@@ -11,9 +11,7 @@ interface StoryHeroProps {
photo?: Photo | null;
slug: string;
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
}
export const StoryHero: React.FC<StoryHeroProps> = ({
@@ -22,8 +20,6 @@ export const StoryHero: React.FC<StoryHeroProps> = ({
stats,
photo,
slug,
protectionLevel = 'standard',
useCanvasRendering = false
}) => {
const formattedDate = date
? new Date(date).toLocaleDateString('en-US', {
@@ -49,7 +45,6 @@ export const StoryHero: React.FC<StoryHeroProps> = ({
className="w-full h-full object-cover"
isGallery={true}
slug={slug}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
/>
) : (
<div className="w-full h-full bg-gray-900" />
@@ -14,9 +14,7 @@ interface StoryPhotoCardProps {
onClick?: () => void;
slug: string;
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
featured?: boolean;
galleryId: string;
}
@@ -28,8 +26,6 @@ export const StoryPhotoCard: React.FC<StoryPhotoCardProps> = ({
onToggleFavorite,
onClick,
slug,
protectionLevel = 'standard',
useCanvasRendering = false,
featured = false,
galleryId: _galleryId
}) => {
@@ -103,7 +99,6 @@ export const StoryPhotoCard: React.FC<StoryPhotoCardProps> = ({
}`}
isGallery={true}
slug={slug}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
/>
)}
</a>
@@ -184,7 +184,7 @@ export const ImageSecurityTab: React.FC = () => {
/>
<Image className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
{t('settings.imageSecurity.enableCanvas', 'Enable canvas rendering by default (advanced protection)')}
{t('settings.imageSecurity.enableCanvas', 'Enable canvas rendering in the lightbox by default (advanced protection)')}
</span>
</label>
</div>
+2 -2
View File
@@ -1938,7 +1938,7 @@
"disableRightClick": "Rechtsklick-Menü blockieren",
"watermarkDownloads": "Wasserzeichen bei Downloads hinzufügen",
"enableDevtoolsProtection": "Entwicklertools erkennen",
"useCanvasRendering": "Canvas-Rendering (erweiterter Schutz)",
"useCanvasRendering": "Canvas-Rendering in der Lightbox (erweiterter Schutz)",
"protectionInfo": "Schutzfunktionen helfen, unerlaubte Downloads zu verhindern, können jedoch nicht alle Methoden blockieren.",
"protectionLevelBasic": "Einfach - Nur Rechtsklick-Sperre",
"protectionLevelStandard": "Standard - Tastenkombinationen blockiert",
@@ -2378,7 +2378,7 @@
"protectionLevel": "Standard-Schutzstufe",
"imageQuality": "Standard-Bildqualität",
"enableDevtools": "DevTools-Erkennung standardmäßig aktivieren",
"enableCanvas": "Canvas-Rendering standardmäßig aktivieren (erweiterter Schutz)",
"enableCanvas": "Canvas-Rendering in der Lightbox standardmäßig aktivieren (erweiterter Schutz)",
"rateLimiting": "Ratenbegrenzung",
"rateLimitingHelp": "Begrenzen Sie, wie viele Bilder angefordert werden können, um Scraping zu verhindern.",
"requestsPerMinute": "Anfragen pro Minute",
+2 -2
View File
@@ -1434,7 +1434,7 @@
"disableRightClick": "Block right-click menu",
"watermarkDownloads": "Add watermark to downloads",
"enableDevtoolsProtection": "Detect developer tools",
"useCanvasRendering": "Canvas rendering (advanced protection)",
"useCanvasRendering": "Canvas rendering in the lightbox (advanced protection)",
"protectionInfo": "Protection features help prevent unauthorized downloads but cannot block all methods.",
"protectionLevelBasic": "Basic - Right-click blocking only",
"protectionLevelStandard": "Standard - Keyboard shortcuts blocked",
@@ -1941,7 +1941,7 @@
"protectionLevel": "Default Protection Level",
"imageQuality": "Default Image Quality",
"enableDevtools": "Enable DevTools detection by default",
"enableCanvas": "Enable canvas rendering by default (advanced protection)",
"enableCanvas": "Enable canvas rendering in the lightbox by default (advanced protection)",
"rateLimiting": "Rate Limiting",
"rateLimitingHelp": "Limit how many images can be requested to prevent scraping.",
"requestsPerMinute": "Requests per minute",
+2 -2
View File
@@ -498,7 +498,7 @@
"disableRightClick": "Bloquer le clic droit",
"watermarkDownloads": "Ajouter un filigrane aux téléchargements",
"enableDevtoolsProtection": "Détecter les outils de développement",
"useCanvasRendering": "Rendu canvas (protection avancée)",
"useCanvasRendering": "Rendu canvas dans la visionneuse (protection avancée)",
"protectionInfo": "Les fonctionnalités de protection aident à prévenir les téléchargements non autorisés mais ne peuvent pas bloquer toutes les méthodes.",
"protectionLevelBasic": "Basique - Blocage du clic droit uniquement",
"protectionLevelStandard": "Standard - Blocage des raccourcis clavier",
@@ -912,7 +912,7 @@
"protectionLevel": "Niveau de protection par défaut",
"imageQuality": "Qualité d'image par défaut",
"enableDevtools": "Activer la détection des outils DevTools par défaut",
"enableCanvas": "Activer le rendu canvas par défaut (protection avancée)",
"enableCanvas": "Activer le rendu canvas dans la visionneuse par défaut (protection avancée)",
"rateLimiting": "Limitation de débit",
"rateLimitingHelp": "Limitez le nombre d'images qui peuvent être demandées pour éviter le scraping.",
"requestsPerMinute": "Requêtes par minute",
+2 -2
View File
@@ -498,7 +498,7 @@
"disableRightClick": "Blokiraj meni desnega klika",
"watermarkDownloads": "Dodaj vodni žig prenosom",
"enableDevtoolsProtection": "Zaznaj orodja za razvijalce",
"useCanvasRendering": "Izris prek canvas (napredna zaščita)",
"useCanvasRendering": "Izris prek canvas v pregledovalniku (napredna zaščita)",
"protectionInfo": "Zaščitne funkcije pomagajo preprečiti nepooblaščene prenose, vendar ne morejo blokirati vseh metod.",
"protectionLevelBasic": "Osnovno - samo blokada desnega klika",
"protectionLevelStandard": "Standardno - blokirane bližnjice na tipkovnici",
@@ -912,7 +912,7 @@
"protectionLevel": "Privzeta raven zaščite",
"imageQuality": "Privzeta kakovost slike",
"enableDevtools": "Privzeto omogoči zaznavanje DevTools",
"enableCanvas": "Privzeto omogoči izris prek canvas (napredna zaščita)",
"enableCanvas": "Privzeto omogoči izris prek canvas v pregledovalniku (napredna zaščita)",
"rateLimiting": "Omejevanje hitrosti",
"rateLimitingHelp": "Omejite, koliko slik je mogoče zahtevati, da preprečite množično pobiranje.",
"requestsPerMinute": "Zahtev na minuto",
@@ -669,7 +669,7 @@ export const EventInformationCard: React.FC<EventInformationCardProps> = ({
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
/>
<Image className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.useCanvasRendering', 'Canvas rendering (advanced protection)')}</span>
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.useCanvasRendering', 'Canvas rendering in the lightbox (advanced protection)')}</span>
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-2">