feat: add Apple Liquid Glass templates, image security settings, and automated releases
## New Features - Apple Liquid Glass CSS template with iOS 26-inspired design - Liquid Glass Dark theme with neon accents - Image Security settings tab with per-event protection levels - Release Please automation for versioning and changelog ## Improvements - Update CSS template migration with final working templates - Add search placeholder visibility fix for glass themes - Update README roadmap (Download Protection, Gallery Templates, Filtering & Export now implemented) ## Infrastructure - Add release-please.yml workflow for automated releases - Add release-please-config.json and manifest - Update docker-build.yml with Release Please integration comments - Add comprehensive CHANGELOG.md ## Cleanup - Add working/planning docs to .gitignore (CLAUDE.md, test-*.md, feature-*.md, etc.) - Remove internal planning documents from git tracking (kept locally) ## Files Added - .github/workflows/release-please.yml - .release-please-manifest.json - release-please-config.json - CHANGELOG.md - frontend/src/features/settings/tabs/ImageSecurityTab.tsx
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
import {
|
||||
getActiveGallerySlug,
|
||||
@@ -67,7 +67,6 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
overlayProtection,
|
||||
fragmentGrid,
|
||||
scrambleFragments,
|
||||
useCanvasRendering,
|
||||
blockKeyboardShortcuts,
|
||||
detectPrintScreen,
|
||||
detectDevTools,
|
||||
@@ -79,6 +78,30 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
const [imageSrc, setImageSrc] = useState<string>('');
|
||||
const [error, setError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [canvasReady, setCanvasReady] = useState(false);
|
||||
const [canvasFailed, setCanvasFailed] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
// Draw image to canvas when canvas rendering is enabled
|
||||
const drawToCanvas = useCallback(() => {
|
||||
if (!useCanvasRendering || !canvasRef.current || !imageRef.current) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const img = imageRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx || !img.complete || img.naturalWidth === 0) return;
|
||||
|
||||
// Set canvas dimensions to match image
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
|
||||
// Draw the image
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
setCanvasReady(true);
|
||||
}, [useCanvasRendering]);
|
||||
|
||||
useEffect(() => {
|
||||
let aborted = false;
|
||||
@@ -93,6 +116,8 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
|
||||
setIsLoading(true);
|
||||
setError(false);
|
||||
setCanvasFailed(false);
|
||||
setCanvasReady(false);
|
||||
|
||||
const resolveSlug = (candidateSrc?: string): string | null => {
|
||||
if (slug) {
|
||||
@@ -181,6 +206,37 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [src, fallbackSrc, slug]);
|
||||
|
||||
// Effect to draw to canvas when image is loaded and canvas rendering is enabled
|
||||
useEffect(() => {
|
||||
if (!useCanvasRendering || !imageSrc) return;
|
||||
|
||||
// Create a hidden image to load and then draw to canvas
|
||||
const img = new Image();
|
||||
// Only set crossOrigin for non-blob URLs (blob URLs are same-origin)
|
||||
// Setting crossOrigin on blob URLs can cause silent failures
|
||||
if (!imageSrc.startsWith('blob:')) {
|
||||
img.crossOrigin = 'anonymous';
|
||||
}
|
||||
|
||||
img.onload = () => {
|
||||
imageRef.current = img;
|
||||
drawToCanvas();
|
||||
};
|
||||
|
||||
img.onerror = (e) => {
|
||||
// Fall back to regular img if canvas loading fails
|
||||
console.warn('Canvas image load failed, falling back to img tag:', e);
|
||||
setCanvasFailed(true);
|
||||
};
|
||||
|
||||
img.src = imageSrc;
|
||||
|
||||
return () => {
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
};
|
||||
}, [imageSrc, useCanvasRendering, drawToCanvas]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={props.className} style={{ backgroundColor: '#f3f4f6', ...props.style }}>
|
||||
@@ -197,5 +253,34 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Canvas rendering mode - only if enabled and not failed
|
||||
if (useCanvasRendering && !canvasFailed) {
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={props.className}
|
||||
style={{
|
||||
...props.style,
|
||||
// Hide canvas until it's ready to prevent flash
|
||||
opacity: canvasReady ? 1 : 0,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
}}
|
||||
// Prevent context menu on canvas
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
onProtectionViolation?.('canvas_context_menu');
|
||||
return false;
|
||||
}}
|
||||
// Prevent drag
|
||||
onDragStart={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
aria-label={alt}
|
||||
role="img"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <img src={imageSrc} alt={alt} {...props} />;
|
||||
};
|
||||
|
||||
@@ -100,14 +100,21 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
setProtectionLevel(data.event.protection_level);
|
||||
}
|
||||
}, [data?.event?.protection_level]);
|
||||
|
||||
// DevTools protection for enhanced and maximum levels
|
||||
|
||||
// Get individual protection settings from event
|
||||
const disableRightClick = data?.event?.disable_right_click === true;
|
||||
const enableDevtoolsProtection = data?.event?.enable_devtools_protection === true;
|
||||
const useCanvasRendering = data?.event?.use_canvas_rendering === true;
|
||||
|
||||
// DevTools protection - enabled by individual setting OR legacy protection level
|
||||
const devToolsEnabled = enableDevtoolsProtection || protectionLevel === 'enhanced' || protectionLevel === 'maximum';
|
||||
|
||||
useDevToolsProtection({
|
||||
enabled: protectionLevel === 'enhanced' || protectionLevel === 'maximum',
|
||||
enabled: devToolsEnabled,
|
||||
detectionSensitivity: protectionLevel === 'maximum' ? 'high' : 'medium',
|
||||
onDevToolsDetected: () => {
|
||||
console.warn('DevTools detected in gallery view');
|
||||
|
||||
|
||||
// Track analytics
|
||||
if (typeof window !== 'undefined' && (window as any).umami) {
|
||||
(window as any).umami.track('gallery_devtools_detected', {
|
||||
@@ -116,7 +123,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
eventId: data?.event?.id
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// For maximum protection, redirect away from gallery
|
||||
if (protectionLevel === 'maximum') {
|
||||
setTimeout(() => {
|
||||
@@ -127,6 +134,21 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
redirectOnDetection: protectionLevel === 'maximum',
|
||||
redirectUrl: '/'
|
||||
});
|
||||
|
||||
// Right-click blocking - separate from DevTools protection
|
||||
useEffect(() => {
|
||||
if (!disableRightClick) return;
|
||||
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
};
|
||||
|
||||
document.addEventListener('contextmenu', handleContextMenu);
|
||||
return () => {
|
||||
document.removeEventListener('contextmenu', handleContextMenu);
|
||||
};
|
||||
}, [disableRightClick]);
|
||||
|
||||
// Data updates are handled by React Query
|
||||
const downloadAllMutation = useDownloadAllPhotos();
|
||||
@@ -679,6 +701,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
allowDownloads={allowDownloads}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={protectionLevel !== 'basic'}
|
||||
disableRightClick={disableRightClick}
|
||||
enableDevtoolsProtection={enableDevtoolsProtection}
|
||||
useCanvasRendering={useCanvasRendering}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,16 +19,22 @@ interface PhotoGridProps {
|
||||
allowDownloads?: boolean;
|
||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
useEnhancedProtection?: boolean;
|
||||
useCanvasRendering?: boolean;
|
||||
disableRightClick?: boolean;
|
||||
enableDevtoolsProtection?: boolean;
|
||||
}
|
||||
|
||||
export const PhotoGrid: React.FC<PhotoGridProps> = ({
|
||||
photos,
|
||||
slug,
|
||||
categoryId,
|
||||
feedbackEnabled = false,
|
||||
export const PhotoGrid: React.FC<PhotoGridProps> = ({
|
||||
photos,
|
||||
slug,
|
||||
categoryId,
|
||||
feedbackEnabled = false,
|
||||
allowDownloads = true,
|
||||
protectionLevel = 'standard',
|
||||
useEnhancedProtection = false
|
||||
useEnhancedProtection = false,
|
||||
useCanvasRendering = false,
|
||||
disableRightClick = false,
|
||||
enableDevtoolsProtection = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
|
||||
@@ -190,6 +196,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({
|
||||
allowDownloads={allowDownloads}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={useCanvasRendering}
|
||||
slug={slug}
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
/>
|
||||
@@ -207,6 +214,8 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({
|
||||
allowDownloads={allowDownloads}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
disableRightClick={disableRightClick}
|
||||
enableDevtoolsProtection={enableDevtoolsProtection}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -222,6 +231,7 @@ interface PhotoThumbnailProps {
|
||||
allowDownloads?: boolean;
|
||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
useEnhancedProtection?: boolean;
|
||||
useCanvasRendering?: boolean;
|
||||
slug: string; // Add slug as required prop
|
||||
feedbackEnabled?: boolean;
|
||||
}
|
||||
@@ -235,6 +245,7 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
||||
allowDownloads = true,
|
||||
protectionLevel = 'standard',
|
||||
useEnhancedProtection = false,
|
||||
useCanvasRendering = false,
|
||||
slug,
|
||||
feedbackEnabled = false
|
||||
}) => {
|
||||
@@ -264,7 +275,7 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
||||
protectFromDownload={!allowDownloads || useEnhancedProtection}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={protectionLevel === 'maximum'}
|
||||
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
|
||||
fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
|
||||
blockKeyboardShortcuts={useEnhancedProtection}
|
||||
detectPrintScreen={useEnhancedProtection}
|
||||
|
||||
@@ -41,6 +41,9 @@ interface PhotoGridWithLayoutsProps {
|
||||
allowDownloads?: boolean;
|
||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
useEnhancedProtection?: boolean;
|
||||
useCanvasRendering?: boolean;
|
||||
disableRightClick?: boolean;
|
||||
enableDevtoolsProtection?: boolean;
|
||||
feedbackOptions?: {
|
||||
allowLikes?: boolean;
|
||||
allowFavorites?: boolean;
|
||||
@@ -51,9 +54,9 @@ interface PhotoGridWithLayoutsProps {
|
||||
onFeedbackChange?: () => void;
|
||||
}
|
||||
|
||||
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
photos,
|
||||
slug,
|
||||
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
photos,
|
||||
slug,
|
||||
categoryId,
|
||||
heroPhotoOverride,
|
||||
isSelectionMode: parentSelectionMode,
|
||||
@@ -64,6 +67,9 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
allowDownloads = true,
|
||||
protectionLevel = 'standard',
|
||||
useEnhancedProtection = false,
|
||||
useCanvasRendering = false,
|
||||
disableRightClick = false,
|
||||
enableDevtoolsProtection = false,
|
||||
onSelectionChange,
|
||||
onToggleSelectionMode: parentToggleSelectionMode,
|
||||
showSelectionControls = true,
|
||||
@@ -185,6 +191,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
allowDownloads,
|
||||
protectionLevel,
|
||||
useEnhancedProtection,
|
||||
useCanvasRendering,
|
||||
isSelectionMode,
|
||||
onPhotoSelect: handlePhotoSelect,
|
||||
eventName,
|
||||
@@ -290,6 +297,9 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
allowDownloads={allowDownloads}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={useCanvasRendering}
|
||||
disableRightClick={disableRightClick}
|
||||
enableDevtoolsProtection={enableDevtoolsProtection}
|
||||
initialShowFeedback={openFeedbackInitially}
|
||||
onFeedbackChange={onFeedbackChange}
|
||||
/>
|
||||
|
||||
@@ -18,8 +18,11 @@ interface PhotoLightboxProps {
|
||||
allowDownloads?: boolean;
|
||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
useEnhancedProtection?: boolean;
|
||||
useCanvasRendering?: boolean;
|
||||
initialShowFeedback?: boolean;
|
||||
onFeedbackChange?: () => void;
|
||||
disableRightClick?: boolean;
|
||||
enableDevtoolsProtection?: boolean;
|
||||
}
|
||||
|
||||
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
@@ -31,8 +34,11 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
allowDownloads = true,
|
||||
protectionLevel = 'standard',
|
||||
useEnhancedProtection = false,
|
||||
useCanvasRendering = false,
|
||||
initialShowFeedback = false,
|
||||
onFeedbackChange,
|
||||
disableRightClick = false,
|
||||
enableDevtoolsProtection = false,
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
@@ -66,13 +72,15 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
const downloadPhotoMutation = useDownloadPhoto();
|
||||
const currentPhoto = photos[currentIndex];
|
||||
|
||||
// DevTools protection for the lightbox when enhanced protection is enabled
|
||||
// DevTools protection - enabled by individual setting OR legacy protection level
|
||||
const devToolsEnabled = enableDevtoolsProtection || (useEnhancedProtection && (protectionLevel === 'enhanced' || protectionLevel === 'maximum'));
|
||||
|
||||
useDevToolsProtection({
|
||||
enabled: useEnhancedProtection && (protectionLevel === 'enhanced' || protectionLevel === 'maximum'),
|
||||
enabled: devToolsEnabled,
|
||||
detectionSensitivity: protectionLevel === 'maximum' ? 'high' : 'medium',
|
||||
onDevToolsDetected: () => {
|
||||
console.warn('DevTools detected in photo lightbox');
|
||||
|
||||
|
||||
// Track analytics
|
||||
if (typeof window !== 'undefined' && (window as any).umami) {
|
||||
(window as any).umami.track('lightbox_devtools_detected', {
|
||||
@@ -82,7 +90,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
gallery: slug
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Close lightbox immediately for maximum protection
|
||||
if (protectionLevel === 'maximum') {
|
||||
onClose();
|
||||
@@ -91,6 +99,21 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
redirectOnDetection: false, // Don't redirect, just close lightbox
|
||||
});
|
||||
|
||||
// Right-click blocking in lightbox
|
||||
useEffect(() => {
|
||||
if (!disableRightClick) return;
|
||||
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
};
|
||||
|
||||
document.addEventListener('contextmenu', handleContextMenu);
|
||||
return () => {
|
||||
document.removeEventListener('contextmenu', handleContextMenu);
|
||||
};
|
||||
}, [disableRightClick]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
@@ -494,7 +517,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
protectFromDownload={!allowDownloads || useEnhancedProtection}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={protectionLevel === 'maximum'}
|
||||
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
|
||||
fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
|
||||
blockKeyboardShortcuts={useEnhancedProtection}
|
||||
detectPrintScreen={useEnhancedProtection}
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface BaseGalleryLayoutProps {
|
||||
allowDownloads?: boolean;
|
||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
useEnhancedProtection?: boolean;
|
||||
useCanvasRendering?: boolean;
|
||||
feedbackEnabled?: boolean;
|
||||
feedbackOptions?: {
|
||||
allowLikes?: boolean;
|
||||
|
||||
@@ -20,6 +20,7 @@ interface GridPhotoProps {
|
||||
slug?: string;
|
||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
useEnhancedProtection?: boolean;
|
||||
useCanvasRendering?: boolean;
|
||||
feedbackEnabled?: boolean;
|
||||
feedbackOptions?: {
|
||||
allowLikes?: boolean;
|
||||
@@ -48,6 +49,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
slug,
|
||||
protectionLevel = 'standard',
|
||||
useEnhancedProtection = false,
|
||||
useCanvasRendering = false,
|
||||
feedbackEnabled = false,
|
||||
feedbackOptions,
|
||||
savedIdentity,
|
||||
@@ -197,7 +199,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
protectFromDownload={!allowDownloads || useEnhancedProtection}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={protectionLevel === 'maximum'}
|
||||
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
|
||||
fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
|
||||
blockKeyboardShortcuts={useEnhancedProtection}
|
||||
detectPrintScreen={useEnhancedProtection}
|
||||
@@ -359,6 +361,7 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
allowDownloads = true,
|
||||
protectionLevel = 'standard',
|
||||
useEnhancedProtection = false,
|
||||
useCanvasRendering = false,
|
||||
feedbackEnabled = false,
|
||||
feedbackOptions
|
||||
}) => {
|
||||
@@ -397,6 +400,7 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
slug={slug}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={useCanvasRendering}
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
feedbackOptions={feedbackOptions}
|
||||
savedIdentity={savedIdentity}
|
||||
|
||||
@@ -35,6 +35,9 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
expiresAt,
|
||||
heroPhotoOverride,
|
||||
allowDownloads = true,
|
||||
protectionLevel = 'standard',
|
||||
useEnhancedProtection = false,
|
||||
useCanvasRendering = false,
|
||||
feedbackEnabled = false,
|
||||
feedbackOptions
|
||||
}) => {
|
||||
@@ -113,7 +116,12 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
alt={heroPhoto.filename}
|
||||
className="w-full h-full object-cover"
|
||||
isGallery={true}
|
||||
protectFromDownload={!allowDownloads}
|
||||
slug={slug}
|
||||
photoId={heroPhoto.id}
|
||||
protectFromDownload={!allowDownloads || useEnhancedProtection}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
|
||||
/>
|
||||
|
||||
{/* Overlay */}
|
||||
@@ -202,7 +210,12 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
className="w-full h-auto object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
isGallery={true}
|
||||
protectFromDownload={!allowDownloads}
|
||||
slug={slug}
|
||||
photoId={photo.id}
|
||||
protectFromDownload={!allowDownloads || useEnhancedProtection}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2">
|
||||
|
||||
Reference in New Issue
Block a user