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">
|
||||
|
||||
@@ -8,6 +8,7 @@ export { GeneralTab } from './tabs/GeneralTab';
|
||||
export { EventsTab } from './tabs/EventsTab';
|
||||
export { StatusTab } from './tabs/StatusTab';
|
||||
export { SecurityTab } from './tabs/SecurityTab';
|
||||
export { ImageSecurityTab } from './tabs/ImageSecurityTab';
|
||||
export { CategoriesTab } from './tabs/CategoriesTab';
|
||||
export { AnalyticsTab } from './tabs/AnalyticsTab';
|
||||
export { ModerationTab } from './tabs/ModerationTab';
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Save, Shield, Monitor, Image, RefreshCw, AlertCircle } from 'lucide-react';
|
||||
import { Button, Card, Loading } from '../../../components/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { api } from '../../../config/api';
|
||||
|
||||
interface ImageSecuritySettings {
|
||||
default_protection_level: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
default_image_quality: number;
|
||||
enable_devtools_protection: boolean;
|
||||
max_image_requests_per_minute: number;
|
||||
max_image_requests_per_5_minutes: number;
|
||||
max_image_requests_per_hour: number;
|
||||
suspicious_activity_threshold: number;
|
||||
enable_canvas_rendering: boolean;
|
||||
default_fragmentation_level: number;
|
||||
security_monitoring_enabled: boolean;
|
||||
block_suspicious_ips: boolean;
|
||||
log_security_events_to_db: boolean;
|
||||
auto_block_threshold: number;
|
||||
}
|
||||
|
||||
const defaultSettings: ImageSecuritySettings = {
|
||||
default_protection_level: 'standard',
|
||||
default_image_quality: 85,
|
||||
enable_devtools_protection: true,
|
||||
max_image_requests_per_minute: 30,
|
||||
max_image_requests_per_5_minutes: 100,
|
||||
max_image_requests_per_hour: 500,
|
||||
suspicious_activity_threshold: 10,
|
||||
enable_canvas_rendering: false,
|
||||
default_fragmentation_level: 3,
|
||||
security_monitoring_enabled: true,
|
||||
block_suspicious_ips: true,
|
||||
log_security_events_to_db: true,
|
||||
auto_block_threshold: 50,
|
||||
};
|
||||
|
||||
export const ImageSecurityTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [settings, setSettings] = useState<ImageSecuritySettings>(defaultSettings);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
// Fetch current settings
|
||||
const { data: fetchedSettings, isLoading, error } = useQuery({
|
||||
queryKey: ['image-security-settings'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/api/admin/image-security/settings');
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
// Update local state when settings are fetched
|
||||
useEffect(() => {
|
||||
if (fetchedSettings) {
|
||||
setSettings({
|
||||
...defaultSettings,
|
||||
...fetchedSettings,
|
||||
});
|
||||
}
|
||||
}, [fetchedSettings]);
|
||||
|
||||
// Save mutation
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (newSettings: ImageSecuritySettings) => {
|
||||
const response = await api.put('/api/admin/image-security/settings', newSettings);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['image-security-settings'] });
|
||||
toast.success(t('settings.imageSecurity.saveSuccess', 'Image security settings saved'));
|
||||
setIsDirty(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('settings.imageSecurity.saveError', 'Failed to save settings'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleChange = <K extends keyof ImageSecuritySettings>(
|
||||
key: K,
|
||||
value: ImageSecuritySettings[K]
|
||||
) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
saveMutation.mutate(settings);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (fetchedSettings) {
|
||||
setSettings({ ...defaultSettings, ...fetchedSettings });
|
||||
setIsDirty(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[200px]">
|
||||
<Loading size="lg" text={t('common.loading')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3 text-red-600">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<p>{t('settings.imageSecurity.loadError', 'Failed to load image security settings')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Default Protection Level */}
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-primary-600" />
|
||||
{t('settings.imageSecurity.defaultProtection', 'Default Protection Settings')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 mb-4">
|
||||
{t('settings.imageSecurity.defaultProtectionHelp', 'These settings apply to all new events. Individual events can override these defaults.')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.imageSecurity.protectionLevel', 'Default Protection Level')}
|
||||
</label>
|
||||
<select
|
||||
value={settings.default_protection_level}
|
||||
onChange={(e) => handleChange('default_protection_level', e.target.value as ImageSecuritySettings['default_protection_level'])}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="basic">{t('events.protectionLevelBasic', 'Basic - Right-click blocking only')}</option>
|
||||
<option value="standard">{t('events.protectionLevelStandard', 'Standard - Keyboard shortcuts blocked')}</option>
|
||||
<option value="enhanced">{t('events.protectionLevelEnhanced', 'Enhanced - Print screen detection')}</option>
|
||||
<option value="maximum">{t('events.protectionLevelMaximum', 'Maximum - DevTools detection & canvas rendering')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.imageSecurity.imageQuality', 'Default Image Quality')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={settings.default_image_quality}
|
||||
onChange={(e) => handleChange('default_image_quality', parseInt(e.target.value) || 85)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">1-100, higher = better quality</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.imageSecurity.fragmentationLevel', 'Fragmentation Level')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={settings.default_fragmentation_level}
|
||||
onChange={(e) => handleChange('default_fragmentation_level', parseInt(e.target.value) || 3)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">1-10, higher = more protection</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enable_devtools_protection}
|
||||
onChange={(e) => handleChange('enable_devtools_protection', e.target.checked)}
|
||||
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Monitor className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-700">
|
||||
{t('settings.imageSecurity.enableDevtools', 'Enable DevTools detection by default')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enable_canvas_rendering}
|
||||
onChange={(e) => handleChange('enable_canvas_rendering', e.target.checked)}
|
||||
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Image className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-700">
|
||||
{t('settings.imageSecurity.enableCanvas', 'Enable canvas rendering by default (advanced protection)')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Rate Limiting */}
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||
{t('settings.imageSecurity.rateLimiting', 'Rate Limiting')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 mb-4">
|
||||
{t('settings.imageSecurity.rateLimitingHelp', 'Limit how many images can be requested to prevent scraping.')}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.imageSecurity.requestsPerMinute', 'Requests per minute')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000"
|
||||
value={settings.max_image_requests_per_minute}
|
||||
onChange={(e) => handleChange('max_image_requests_per_minute', parseInt(e.target.value) || 30)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.imageSecurity.requestsPer5Minutes', 'Requests per 5 min')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="5000"
|
||||
value={settings.max_image_requests_per_5_minutes}
|
||||
onChange={(e) => handleChange('max_image_requests_per_5_minutes', parseInt(e.target.value) || 100)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.imageSecurity.requestsPerHour', 'Requests per hour')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
value={settings.max_image_requests_per_hour}
|
||||
onChange={(e) => handleChange('max_image_requests_per_hour', parseInt(e.target.value) || 500)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Security Monitoring */}
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||
{t('settings.imageSecurity.securityMonitoring', 'Security Monitoring')}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.imageSecurity.suspiciousThreshold', 'Suspicious activity threshold')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={settings.suspicious_activity_threshold}
|
||||
onChange={(e) => handleChange('suspicious_activity_threshold', parseInt(e.target.value) || 10)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">Violations before flagging as suspicious</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.imageSecurity.autoBlockThreshold', 'Auto-block threshold')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
value={settings.auto_block_threshold}
|
||||
onChange={(e) => handleChange('auto_block_threshold', parseInt(e.target.value) || 50)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">Violations before auto-blocking IP</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.security_monitoring_enabled}
|
||||
onChange={(e) => handleChange('security_monitoring_enabled', e.target.checked)}
|
||||
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700">
|
||||
{t('settings.imageSecurity.enableMonitoring', 'Enable security monitoring')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.block_suspicious_ips}
|
||||
onChange={(e) => handleChange('block_suspicious_ips', e.target.checked)}
|
||||
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700">
|
||||
{t('settings.imageSecurity.blockSuspiciousIps', 'Automatically block suspicious IPs')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.log_security_events_to_db}
|
||||
onChange={(e) => handleChange('log_security_events_to_db', e.target.checked)}
|
||||
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700">
|
||||
{t('settings.imageSecurity.logEvents', 'Log security events to database')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Info Box */}
|
||||
<Card padding="md" className="bg-blue-50 border-blue-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-medium mb-1">{t('settings.imageSecurity.infoTitle', 'About Image Protection')}</p>
|
||||
<p>
|
||||
{t('settings.imageSecurity.infoText', 'These protection features help prevent casual downloading and copying but cannot block all methods. Determined users may still find ways to capture images. Consider using watermarks and legal agreements for comprehensive protection.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
isLoading={saveMutation.isPending}
|
||||
leftIcon={<Save className="w-5 h-5" />}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
{t('common.saveChanges', 'Save Changes')}
|
||||
</Button>
|
||||
|
||||
{isDirty && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
leftIcon={<RefreshCw className="w-5 h-5" />}
|
||||
>
|
||||
{t('common.resetChanges', 'Reset Changes')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -693,6 +693,12 @@
|
||||
"downloadPermissions": "Download-Berechtigungen",
|
||||
"downloadsEnabled": "Downloads aktiviert",
|
||||
"downloadsDisabled": "Downloads deaktiviert",
|
||||
"downloadProtection": "Download-Schutz",
|
||||
"disableRightClick": "Rechtsklick-Menü blockieren",
|
||||
"watermarkDownloads": "Wasserzeichen bei Downloads hinzufügen",
|
||||
"enableDevtoolsProtection": "Entwicklertools erkennen",
|
||||
"useCanvasRendering": "Canvas-Rendering (erweiterter Schutz)",
|
||||
"protectionInfo": "Schutzfunktionen helfen, unerlaubte Downloads zu verhindern, können jedoch nicht alle Methoden blockieren.",
|
||||
"heroPhoto": "Hero-Foto",
|
||||
"heroPhotoHelp": "Wählen Sie ein hervorgehobenes Foto für das Hero-Galerie-Layout",
|
||||
"selectHeroPhoto": "Hero-Foto auswählen",
|
||||
|
||||
@@ -371,6 +371,12 @@
|
||||
"downloadPermissions": "Download Permissions",
|
||||
"downloadsEnabled": "Downloads Enabled",
|
||||
"downloadsDisabled": "Downloads Disabled",
|
||||
"downloadProtection": "Download Protection",
|
||||
"disableRightClick": "Block right-click menu",
|
||||
"watermarkDownloads": "Add watermark to downloads",
|
||||
"enableDevtoolsProtection": "Detect developer tools",
|
||||
"useCanvasRendering": "Canvas rendering (advanced protection)",
|
||||
"protectionInfo": "Protection features help prevent unauthorized downloads but cannot block all methods.",
|
||||
"heroPhoto": "Hero Photo",
|
||||
"heroPhotoHelp": "Select a featured photo for the hero gallery layout",
|
||||
"selectHeroPhoto": "Select Hero Photo",
|
||||
|
||||
@@ -21,7 +21,11 @@ import {
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Type
|
||||
Type,
|
||||
Shield,
|
||||
Monitor,
|
||||
Droplets,
|
||||
MousePointer
|
||||
} from 'lucide-react';
|
||||
import { parseISO, differenceInDays, isValid } from 'date-fns';
|
||||
|
||||
@@ -147,6 +151,13 @@ export const EventDetailsPage: React.FC = () => {
|
||||
require_password: boolean;
|
||||
new_password: string;
|
||||
confirm_new_password: string;
|
||||
// Download protection settings
|
||||
protection_level: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
disable_right_click: boolean;
|
||||
allow_downloads: boolean;
|
||||
watermark_downloads: boolean;
|
||||
enable_devtools_protection: boolean;
|
||||
use_canvas_rendering: boolean;
|
||||
};
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@@ -163,6 +174,13 @@ export const EventDetailsPage: React.FC = () => {
|
||||
require_password: true,
|
||||
new_password: '',
|
||||
confirm_new_password: '',
|
||||
// Download protection settings
|
||||
protection_level: 'standard',
|
||||
disable_right_click: true,
|
||||
allow_downloads: true,
|
||||
watermark_downloads: false,
|
||||
enable_devtools_protection: true,
|
||||
use_canvas_rendering: false,
|
||||
});
|
||||
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
|
||||
feedback_enabled: false,
|
||||
@@ -367,6 +385,13 @@ export const EventDetailsPage: React.FC = () => {
|
||||
require_password: normalizeRequirePassword(event.require_password),
|
||||
new_password: '',
|
||||
confirm_new_password: '',
|
||||
// Load protection settings from event
|
||||
protection_level: event.protection_level || 'standard',
|
||||
disable_right_click: event.disable_right_click ?? true,
|
||||
allow_downloads: event.allow_downloads ?? true,
|
||||
watermark_downloads: event.watermark_downloads ?? false,
|
||||
enable_devtools_protection: event.enable_devtools_protection ?? true,
|
||||
use_canvas_rendering: event.use_canvas_rendering ?? false,
|
||||
});
|
||||
|
||||
setShowNewPassword(false);
|
||||
@@ -449,6 +474,13 @@ export const EventDetailsPage: React.FC = () => {
|
||||
expires_at: editForm.expires_at,
|
||||
allow_user_uploads: editForm.allow_user_uploads,
|
||||
require_password: editForm.require_password,
|
||||
// Download protection settings
|
||||
protection_level: editForm.protection_level,
|
||||
disable_right_click: editForm.disable_right_click,
|
||||
allow_downloads: editForm.allow_downloads,
|
||||
watermark_downloads: editForm.watermark_downloads,
|
||||
enable_devtools_protection: editForm.enable_devtools_protection,
|
||||
use_canvas_rendering: editForm.use_canvas_rendering,
|
||||
};
|
||||
|
||||
// Only include fields that have defined values
|
||||
@@ -940,12 +972,81 @@ export const EventDetailsPage: React.FC = () => {
|
||||
|
||||
{/* Feedback Settings */}
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 mb-3">{t('feedback.settings', 'Feedback Settings')}</h3>
|
||||
<h3 className="text-sm font-semibold text-neutral-900 mb-3">{t('feedback.settings.title', 'Guest Feedback Settings')}</h3>
|
||||
<FeedbackSettings
|
||||
settings={feedbackSettings}
|
||||
onChange={setFeedbackSettings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Download Protection Settings */}
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 mb-3 flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-primary-600" />
|
||||
{t('events.downloadProtection', 'Download Protection')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.allow_downloads}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, allow_downloads: e.target.checked }))}
|
||||
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Download className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-700">{t('events.allowDownloads', 'Allow photo downloads')}</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.disable_right_click}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, disable_right_click: e.target.checked }))}
|
||||
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<MousePointer className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-700">{t('events.disableRightClick', 'Block right-click menu')}</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.watermark_downloads}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, watermark_downloads: e.target.checked }))}
|
||||
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Droplets className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-700">{t('events.watermarkDownloads', 'Add watermark to downloads')}</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.enable_devtools_protection}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, enable_devtools_protection: e.target.checked }))}
|
||||
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Monitor className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-700">{t('events.enableDevtoolsProtection', 'Detect developer tools')}</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.use_canvas_rendering}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, use_canvas_rendering: e.target.checked }))}
|
||||
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Image className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-700">{t('events.useCanvasRendering', 'Canvas rendering (advanced protection)')}</span>
|
||||
</label>
|
||||
|
||||
<p className="text-xs text-neutral-500 mt-2">
|
||||
{t('events.protectionInfo', 'Protection features help prevent unauthorized downloads but cannot block all methods.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<dl className="space-y-4">
|
||||
@@ -1037,6 +1138,50 @@ export const EventDetailsPage: React.FC = () => {
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{/* Download Protection Display */}
|
||||
<div className="pt-3 mt-3 border-t border-neutral-200">
|
||||
<dt className="text-sm font-medium text-neutral-500 flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
{t('events.downloadProtection', 'Download Protection')}
|
||||
</dt>
|
||||
<dd className="mt-2 text-sm text-neutral-900">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className={`inline-flex items-center px-2 py-1 text-xs font-medium rounded ${
|
||||
event.protection_level === 'maximum' ? 'bg-red-100 text-red-700' :
|
||||
event.protection_level === 'enhanced' ? 'bg-orange-100 text-orange-700' :
|
||||
event.protection_level === 'standard' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-neutral-100 text-neutral-700'
|
||||
}`}>
|
||||
{event.protection_level || 'standard'}
|
||||
</span>
|
||||
{event.disable_right_click && (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
|
||||
<MousePointer className="w-3 h-3 mr-1" />
|
||||
{t('events.rightClickBlocked', 'Right-click blocked')}
|
||||
</span>
|
||||
)}
|
||||
{event.enable_devtools_protection && (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
|
||||
<Monitor className="w-3 h-3 mr-1" />
|
||||
{t('events.devtoolsDetection', 'DevTools detection')}
|
||||
</span>
|
||||
)}
|
||||
{!event.allow_downloads && (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-red-100 text-red-700 rounded">
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
{t('events.downloadsDisabled', 'Downloads disabled')}
|
||||
</span>
|
||||
)}
|
||||
{event.watermark_downloads && (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
|
||||
<Droplets className="w-3 h-3 mr-1" />
|
||||
{t('events.watermarked', 'Watermarked')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -7,13 +7,14 @@ import {
|
||||
EventsTab,
|
||||
StatusTab,
|
||||
SecurityTab,
|
||||
ImageSecurityTab,
|
||||
CategoriesTab,
|
||||
AnalyticsTab,
|
||||
ModerationTab,
|
||||
StylingTab,
|
||||
} from '../../features/settings';
|
||||
|
||||
type TabType = 'general' | 'events' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation' | 'styling';
|
||||
type TabType = 'general' | 'events' | 'status' | 'security' | 'imageSecurity' | 'categories' | 'analytics' | 'moderation' | 'styling';
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('general');
|
||||
@@ -68,6 +69,7 @@ export const SettingsPage: React.FC = () => {
|
||||
{ key: 'events', label: t('settings.events.title', 'Event Creation') },
|
||||
{ key: 'status', label: t('settings.systemStatus.title') },
|
||||
{ key: 'security', label: t('settings.security.title') },
|
||||
{ key: 'imageSecurity', label: t('settings.imageSecurity.title', 'Image Protection') },
|
||||
{ key: 'categories', label: t('settings.categories.title') },
|
||||
{ key: 'analytics', label: t('settings.analytics.title') },
|
||||
{ key: 'moderation', label: t('settings.moderation.title', 'Moderation') },
|
||||
@@ -151,6 +153,8 @@ export const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'imageSecurity' && <ImageSecurityTab />}
|
||||
|
||||
{activeTab === 'categories' && <CategoriesTab />}
|
||||
|
||||
{activeTab === 'analytics' && (
|
||||
|
||||
@@ -34,6 +34,13 @@ export interface Event {
|
||||
unique_visitors?: number;
|
||||
source_mode?: 'managed' | 'reference' | string;
|
||||
external_path?: string | null;
|
||||
// Download protection fields
|
||||
allow_downloads?: boolean;
|
||||
protection_level?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
disable_right_click?: boolean;
|
||||
watermark_downloads?: boolean;
|
||||
enable_devtools_protection?: boolean;
|
||||
use_canvas_rendering?: boolean;
|
||||
}
|
||||
|
||||
export interface GalleryInfo {
|
||||
@@ -56,15 +63,13 @@ export interface Photo {
|
||||
download_url_template?: string;
|
||||
requires_token?: boolean;
|
||||
type: 'collage' | 'individual' | 'video';
|
||||
media_type?: 'photo' | 'video';
|
||||
mime_type?: string;
|
||||
category_id?: number | string | null;
|
||||
category_name?: string;
|
||||
category_slug?: string;
|
||||
size: number;
|
||||
uploaded_at: string;
|
||||
// Media type fields
|
||||
media_type?: 'image' | 'video';
|
||||
media_type?: 'photo' | 'video' | 'image';
|
||||
mime_type?: string;
|
||||
duration?: number; // Duration in seconds for videos
|
||||
video_codec?: string;
|
||||
@@ -107,6 +112,7 @@ export interface GalleryData {
|
||||
protection_level?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
image_quality?: number;
|
||||
use_canvas_rendering?: boolean;
|
||||
enable_devtools_protection?: boolean;
|
||||
fragmentation_level?: number;
|
||||
overlay_protection?: boolean;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user