Compare commits

...

13 Commits

Author SHA1 Message Date
Paul Nothaft 41f80fc898 Merge pull request #111 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m17s
Build and Push Docker Images / build-frontend (push) Failing after 3m42s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.3.4
2026-01-15 10:24:59 -05:00
github-actions[bot] 0f7551ab5b chore(main): release 2.3.4 2026-01-15 15:24:23 +00:00
Paul Nothaft 7c58749806 Merge pull request #110 from the-luap/fix/codeql-v4-upgrade
fix: database migration restart bug, lightbox loading spinner, and watermark cache invalidation
2026-01-15 10:24:04 -05:00
Paul Nothaft 050ed37819 fix: add lightbox loading spinner and watermark cache invalidation
- Add spinning loader in lightbox while large images are loading
- Add onLoad callback to AuthenticatedImage for canvas and img modes
- Add ETag headers based on watermark settings for HTTP cache validation
- Add watermark version query param to photo/thumbnail URLs for cache busting
- Ensures images refresh when watermark settings are enabled/changed
2026-01-15 16:19:22 +01:00
Paul Nothaft 83a4344a01 fix: prevent database migration restart failures
- Move migrations table insert inside PostgreSQL transaction for atomicity
- Add PostgreSQL error codes 42701 (duplicate column), 42710 (duplicate
  object), and 23505 (unique violation) to error handling
- Make migrations 006 and 008 idempotent with column existence checks

Fixes #107
2026-01-15 15:43:13 +01:00
Paul Nothaft 9b50f3d6b7 Merge pull request #109 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m36s
Build and Push Docker Images / build-frontend (push) Failing after 3m21s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.3.3
2026-01-15 09:24:12 -05:00
github-actions[bot] e945bc9413 chore(main): release 2.3.3 2026-01-15 14:23:34 +00:00
Paul Nothaft 3b720ed56e fix: lightbox watermark loading, white label translations, and dynamic footer year (#108)
fix: lightbox watermark loading, white label translations, and dynamic footer year
2026-01-15 09:23:10 -05:00
Paul Nothaft ce8587b24d fix: lightbox watermark loading, white label translations, and dynamic footer year
- Fix watermarked images not opening in lightbox (add /api prefix to photo URLs)
- Add i18n translations for 'White Label' and 'Hide Powered by' branding settings
- Add complete logo customization translations (EN and DE)
- Replace hardcoded © 2024 with dynamic current year in footer
- Use company name from settings in default footer text
2026-01-15 14:16:00 +01:00
Paul Nothaft fe772b52d6 Merge pull request #106 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 4m6s
Build and Push Docker Images / build-frontend (push) Failing after 3m56s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.3.2
2026-01-15 08:03:07 -05:00
github-actions[bot] f29b77998b chore(main): release 2.3.2 2026-01-15 13:02:43 +00:00
Paul Nothaft f843e4c25c Merge pull request #105 from the-luap/fix/codeql-v4-upgrade
fix: watermark thumbnails, custom logo display, and German translations
2026-01-15 08:02:28 -05:00
Paul Nothaft ea20446a79 fix: watermark thumbnails, custom logo display, and German translations
- Fix thumbnail display when watermarks enabled globally on existing galleries
  - Backend: Apply watermarks to thumbnails at the thumbnail endpoint
  - Frontend: Remove hack that redirected thumbnails to photo endpoint
- Fix custom logo display in gallery hero sections
  - Only apply brightness/invert filter to default PicPeak logo
  - Custom logos now display as-is with drop-shadow only
- Add German translations for Event Creation and Image Protection settings
  - settings.events: Pflichtfelder, Kundenname/E-Mail erforderlich, etc.
  - settings.imageSecurity: Bildschutz, Ratenbegrenzung, Sicherheitsüberwachung
  - Protection level options in both EN and DE locales
2026-01-15 13:57:22 +01:00
19 changed files with 309 additions and 77 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "2.3.1"
".": "2.3.4"
}
+26
View File
@@ -5,6 +5,32 @@ All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.3.4](https://github.com/the-luap/picpeak/compare/v2.3.3...v2.3.4) (2026-01-15)
### Bug Fixes
* add lightbox loading spinner and watermark cache invalidation ([050ed37](https://github.com/the-luap/picpeak/commit/050ed378199eb3b15c7c7f243792f68f858803f5))
* database migration restart bug, lightbox loading spinner, and watermark cache invalidation ([7c58749](https://github.com/the-luap/picpeak/commit/7c5874980640ae8c3d1050ce24daeb0a2aeab7a3))
* prevent database migration restart failures ([83a4344](https://github.com/the-luap/picpeak/commit/83a4344a01de4f65c5024fdf2d177a04457ccd2f)), closes [#107](https://github.com/the-luap/picpeak/issues/107)
## [2.3.3](https://github.com/the-luap/picpeak/compare/v2.3.2...v2.3.3) (2026-01-15)
### Bug Fixes
* lightbox watermark loading, white label translations, and dynamic footer year ([3b720ed](https://github.com/the-luap/picpeak/commit/3b720ed56ecd2ded6aec57309f8c408c63a617ef))
* lightbox watermark loading, white label translations, and dynamic footer year ([ce8587b](https://github.com/the-luap/picpeak/commit/ce8587b24df3f53a11a74348eff8b5c5b96c5488))
* lightbox watermark loading, white label translations, and dynamic footer year ([#108](https://github.com/the-luap/picpeak/issues/108)) ([3b720ed](https://github.com/the-luap/picpeak/commit/3b720ed56ecd2ded6aec57309f8c408c63a617ef))
## [2.3.2](https://github.com/the-luap/picpeak/compare/v2.3.1...v2.3.2) (2026-01-15)
### Bug Fixes
* watermark thumbnails, custom logo display, and German translations ([f843e4c](https://github.com/the-luap/picpeak/commit/f843e4c25cef02eef354fd3ee25824e20e4f8fc8))
* watermark thumbnails, custom logo display, and German translations ([ea20446](https://github.com/the-luap/picpeak/commit/ea20446a797a00cf45dbe7bf6f06574a79c4d8a6))
## [2.3.1](https://github.com/the-luap/picpeak/compare/v2.3.0...v2.3.1) (2026-01-15)
@@ -1,22 +1,27 @@
exports.up = async function(knex) {
// Add photo_counter column to photo_categories table
await knex.schema.alterTable('photo_categories', function(table) {
table.integer('photo_counter').defaultTo(0).notNullable();
});
// Check if photo_counter column already exists to make migration idempotent
const hasPhotoCounter = await knex.schema.hasColumn('photo_categories', 'photo_counter');
// Initialize counters based on existing photos
const categories = await knex('photo_categories').select('id');
for (const category of categories) {
const photoCount = await knex('photos')
.where('category_id', category.id)
.count('id as count')
.first();
if (photoCount && photoCount.count > 0) {
await knex('photo_categories')
.where('id', category.id)
.update({ photo_counter: photoCount.count });
if (!hasPhotoCounter) {
// Add photo_counter column to photo_categories table
await knex.schema.alterTable('photo_categories', function(table) {
table.integer('photo_counter').defaultTo(0).notNullable();
});
// Initialize counters based on existing photos
const categories = await knex('photo_categories').select('id');
for (const category of categories) {
const photoCount = await knex('photos')
.where('category_id', category.id)
.count('id as count')
.first();
if (photoCount && photoCount.count > 0) {
await knex('photo_categories')
.where('id', category.id)
.update({ photo_counter: photoCount.count });
}
}
}
};
@@ -1,23 +1,33 @@
exports.up = async function(knex) {
// Add language-specific columns to email_templates
await knex.schema.alterTable('email_templates', function(table) {
// Add English versions (rename existing columns for consistency)
table.renameColumn('subject', 'subject_en');
table.renameColumn('body_html', 'body_html_en');
table.renameColumn('body_text', 'body_text_en');
// Add German versions
table.string('subject_de');
table.text('body_html_de');
table.text('body_text_de');
});
// Check which columns already exist to make migration idempotent
const hasSubjectEn = await knex.schema.hasColumn('email_templates', 'subject_en');
const hasSubjectDe = await knex.schema.hasColumn('email_templates', 'subject_de');
const hasSubjectOriginal = await knex.schema.hasColumn('email_templates', 'subject');
// Copy existing values to German columns as defaults
await knex('email_templates').update({
subject_de: knex.raw('subject_en'),
body_html_de: knex.raw('body_html_en'),
body_text_de: knex.raw('body_text_en')
});
// Only rename columns if they haven't been renamed yet
if (hasSubjectOriginal && !hasSubjectEn) {
await knex.schema.alterTable('email_templates', function(table) {
table.renameColumn('subject', 'subject_en');
table.renameColumn('body_html', 'body_html_en');
table.renameColumn('body_text', 'body_text_en');
});
}
// Only add German columns if they don't exist
if (!hasSubjectDe) {
await knex.schema.alterTable('email_templates', function(table) {
table.string('subject_de');
table.text('body_html_de');
table.text('body_text_de');
});
// Copy existing values to German columns as defaults
await knex('email_templates').update({
subject_de: knex.raw('subject_en'),
body_html_de: knex.raw('body_html_en'),
body_text_de: knex.raw('body_text_en')
});
}
};
exports.down = async function(knex) {
+17 -6
View File
@@ -67,26 +67,37 @@ async function runMigrationSafely(filepath) {
const migrationPath = path.join(__dirname, filepath);
const migration = require(migrationPath);
const filename = path.basename(filepath);
if (migration.up) {
console.log(`Running migration: ${filepath}`);
// Run migration in a transaction if possible
// IMPORTANT: Include the migrations table insert INSIDE the transaction
// to ensure atomicity between schema changes and tracking
if (db.client.config.client === 'pg') {
await db.transaction(async (trx) => {
await migration.up(trx);
// Insert migration record inside transaction for atomicity
await trx('migrations').insert({ filename });
});
} else {
await migration.up(db);
await db('migrations').insert({ filename });
}
await db('migrations').insert({ filename });
console.log(`Migration ${filepath} completed successfully`);
}
} catch (error) {
// Check if error is because schema already exists
if (error.code === '42P07' || // PostgreSQL: relation already exists
error.code === 'SQLITE_ERROR' && error.message.includes('already exists')) {
// PostgreSQL error codes:
// - 42P07: duplicate_table (relation already exists)
// - 42701: duplicate_column (column already exists)
// - 42710: duplicate_object (constraint, index, etc. already exists)
// - 23505: unique_violation (migration record already exists)
const schemaExistsErrors = ['42P07', '42701', '42710', '23505'];
const isSQLiteAlreadyExists = error.code === 'SQLITE_ERROR' && error.message.includes('already exists');
if (schemaExistsErrors.includes(error.code) || isSQLiteAlreadyExists) {
console.log(`Migration ${filepath} - schema already exists, marking as applied`);
await markMigrationAsApplied(path.basename(filepath));
} else {
+14 -3
View File
@@ -26,11 +26,22 @@ async function runMigration(filepath) {
const migrationPath = path.join(__dirname, filepath);
const migration = require(migrationPath);
const filename = path.basename(filepath);
if (migration.up) {
console.log(`Running migration: ${filepath}`);
await migration.up(db);
await db('migrations').insert({ filename });
// Run migration in a transaction if PostgreSQL to ensure atomicity
// between schema changes and migration tracking
if (db.client.config.client === 'pg') {
await db.transaction(async (trx) => {
await migration.up(trx);
await trx('migrations').insert({ filename });
});
} else {
await migration.up(db);
await db('migrations').insert({ filename });
}
console.log(`Migration ${filepath} completed`);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "2.3.1",
"version": "2.3.4",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
+55 -8
View File
@@ -172,7 +172,13 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
try {
// Get filter parameters from query
const { filter, guest_id } = req.query;
// Get watermark settings to generate cache-busting version for URLs
const watermarkSettings = await watermarkService.getWatermarkSettings();
const wmVersion = watermarkSettings?.enabled
? `wm=${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '';
// First get all photos
let photos = await db('photos')
.where('photos.event_id', req.event.id)
@@ -327,15 +333,17 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
categories: categories,
photos: photos.map(photo => {
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
const photoUrl = useJwtUrl ?
`/api/gallery/${req.params.slug}/photo/${photo.id}` :
// Add watermark version to URLs for cache busting when settings change
const wmQuery = wmVersion ? `?${wmVersion}` : '';
const photoUrl = useJwtUrl ?
`/api/gallery/${req.params.slug}/photo/${photo.id}${wmQuery}` :
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
return {
id: photo.id,
filename: photo.filename,
url: photoUrl,
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : null,
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}${wmQuery}` : null,
secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`,
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
type: photo.type,
@@ -754,6 +762,20 @@ router.get('/:slug/photo/:photoId',
// Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings();
// Generate ETag based on photo id, modification time, and watermark settings
// This ensures cache invalidation when watermark settings change
const fs = require('fs');
const stat = fs.statSync(filePath);
const watermarkHash = watermarkSettings?.enabled
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '-nowm';
const etag = `"${photoId}-${stat.mtime.getTime()}${watermarkHash}"`;
// Check if client has valid cached version
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark and send
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
@@ -761,6 +783,7 @@ router.get('/:slug/photo/:photoId',
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes
'ETag': etag,
'X-Protection-Level': 'basic'
});
@@ -769,6 +792,7 @@ router.get('/:slug/photo/:photoId',
// Send original file with basic protection headers
res.set({
'Cache-Control': 'private, max-age=1800',
'ETag': etag,
'X-Protection-Level': 'basic'
});
// Ensure absolute path for res.sendFile
@@ -820,17 +844,40 @@ router.get('/:slug/thumbnail/:photoId',
'thumbnail'
);
// Check if watermarks are enabled and apply to thumbnail
const watermarkSettings = await watermarkService.getWatermarkSettings();
// Generate ETag based on photo id, thumbnail modification time, and watermark settings
const fs = require('fs');
const stat = fs.statSync(thumbPath);
const watermarkHash = watermarkSettings?.enabled
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '-nowm';
const etag = `"thumb-${photoId}-${stat.mtime.getTime()}${watermarkHash}"`;
// Check if client has valid cached version
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
// Set appropriate headers with enhanced security
res.set({
'Content-Type': 'image/jpeg',
'Cache-Control': 'private, max-age=1800', // Reduced cache time
'Cross-Origin-Resource-Policy': 'cross-origin',
'X-Content-Type-Options': 'nosniff',
'X-Protected-Thumbnail': 'true'
'X-Protected-Thumbnail': 'true',
'ETag': etag
});
// Send file
res.sendFile(path.resolve(thumbPath));
if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark to thumbnail
const watermarkedBuffer = await watermarkService.applyWatermark(thumbPath, watermarkSettings);
res.send(watermarkedBuffer);
} else {
// Send file without watermark
res.sendFile(path.resolve(thumbPath));
}
} catch (error) {
logger.error('Error serving thumbnail:', {
error: error.message,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "2.3.1",
"version": "2.3.4",
"type": "module",
"scripts": {
"dev": "vite",
@@ -7,7 +7,7 @@ import {
resolveSlugFromRequestUrl,
} from '../../utils/galleryAuthStorage';
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
interface AuthenticatedImageProps extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'onLoad'> {
src: string;
fallbackSrc?: string;
useWatermark?: boolean;
@@ -29,6 +29,7 @@ interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageEleme
detectDevTools?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
onLoad?: () => void;
}
export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
@@ -54,6 +55,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
detectDevTools,
protectionLevel,
useEnhancedProtection,
onLoad,
...props
}) => {
const unusedProps = {
@@ -221,6 +223,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
img.onload = () => {
imageRef.current = img;
drawToCanvas();
onLoad?.();
};
img.onerror = (e) => {
@@ -235,7 +238,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
img.onload = null;
img.onerror = null;
};
}, [imageSrc, useCanvasRendering, drawToCanvas]);
}, [imageSrc, useCanvasRendering, drawToCanvas, onLoad]);
if (isLoading) {
return (
@@ -282,5 +285,5 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
);
}
return <img src={imageSrc} alt={alt} {...props} />;
return <img src={imageSrc} alt={alt} onLoad={onLoad} {...props} />;
};
@@ -366,7 +366,10 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
className={`${heroLogoSize.className} w-auto object-contain mx-auto`}
style={{
...(heroLogoSize.style || {}),
filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
// Only apply brightness/invert filter to default logo; custom logos display as-is
filter: brandingSettings?.logo_url
? 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
}}
/>
{shouldShowCompanyName() && brandingSettings?.company_name && (
@@ -441,7 +444,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
</p>
)}
<p className="text-xs sm:text-sm text-neutral-500">
{brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'}
{brandingSettings?.footer_text || `© ${new Date().getFullYear()}${brandingSettings?.company_name ? ` ${brandingSettings.company_name}` : ''}. All rights reserved.`}
{!brandingSettings?.hide_powered_by && (
<> | Powered by <span className="font-semibold">PicPeak</span></>
)}
@@ -203,7 +203,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
company_name: settingsData.branding_company_name || '',
company_tagline: settingsData.branding_company_tagline || '',
support_email: settingsData.branding_support_email || '',
footer_text: settingsData.branding_footer_text || '© 2024 Your Company. All rights reserved.',
footer_text: settingsData.branding_footer_text || '',
watermark_enabled: settingsData.branding_watermark_enabled || false,
logo_url: settingsData.branding_logo_url || null,
logo_size: settingsData.branding_logo_size || 'medium',
@@ -380,12 +380,12 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
});
// Transform URLs for watermarks if enabled
// Transform full-size URLs for watermarks if enabled
// Note: Thumbnails are watermarked server-side at the thumbnail endpoint
if (watermarkEnabled) {
photos = photos.map(photo => ({
...photo,
url: `/gallery/${slug}/photo/${photo.id}`,
thumbnail_url: `/gallery/${slug}/photo/${photo.id}` // Use watermarked version for thumbnails too
url: `/api/gallery/${slug}/photo/${photo.id}`
}));
}
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react';
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star, Loader2 } from 'lucide-react';
import type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery';
import { AuthenticatedImage } from '../common';
@@ -62,12 +62,18 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'rating'; rating?: number }>(null);
const [imageLoaded, setImageLoaded] = useState(false);
useEffect(() => {
const onResize = () => setIsSmallScreen(window.innerWidth < 640);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
// Reset image loaded state when changing photos
useEffect(() => {
setImageLoaded(false);
}, [currentIndex]);
const downloadPhotoMutation = useDownloadPhoto();
const currentPhoto = photos[currentIndex];
@@ -488,6 +494,13 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0,
}}
>
{/* Loading spinner */}
{!imageLoaded && currentPhoto.media_type !== 'video' && (
<div className="absolute inset-0 flex items-center justify-center z-10">
<Loader2 className="w-12 h-12 text-white animate-spin" />
</div>
)}
{currentPhoto.media_type === 'video' ? (
<VideoPlayer
src={currentPhoto.url}
@@ -505,8 +518,10 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
style={{
transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`,
transition: isDragging ? 'none' : 'transform 0.2s',
opacity: imageLoaded ? 1 : 0,
}}
draggable={false}
onLoad={() => setImageLoaded(true)}
useWatermark={useEnhancedProtection}
watermarkText={useEnhancedProtection ? `${currentPhoto.filename} - Protected` : undefined}
isGallery={true}
@@ -524,7 +539,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
detectDevTools={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
onProtectionViolation={(violationType) => {
console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`);
// Track analytics
if (typeof window !== 'undefined' && (window as any).umami) {
(window as any).umami.track('lightbox_protection_violation', {
@@ -534,7 +549,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
zoom
});
}
// For maximum protection, close lightbox on violation
if (protectionLevel === 'maximum' &&
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {
@@ -142,8 +142,11 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
}
alt="Event logo"
className="h-20 sm:h-24 lg:h-32 mx-auto"
style={{
filter: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
style={{
// Only apply brightness/invert filter to default logo; custom logos display as-is
filter: eventLogo
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
}}
/>
</div>
+71 -1
View File
@@ -778,6 +778,10 @@
"enableDevtoolsProtection": "Entwicklertools erkennen",
"useCanvasRendering": "Canvas-Rendering (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",
"protectionLevelEnhanced": "Erweitert - Bildschirmfoto-Erkennung",
"protectionLevelMaximum": "Maximum - DevTools-Erkennung & Canvas-Rendering",
"heroPhoto": "Hero-Foto",
"heroPhotoHelp": "Wählen Sie ein hervorgehobenes Foto für das Hero-Galerie-Layout",
"selectHeroPhoto": "Hero-Foto auswählen",
@@ -1032,6 +1036,48 @@
"saveSettings": "Sicherheitseinstellungen speichern",
"saveSecuritySettings": "Sicherheitseinstellungen speichern"
},
"events": {
"title": "Veranstaltungserstellung",
"requiredFields": "Pflichtfelder",
"requiredFieldsDescription": "Konfigurieren Sie, welche Kontaktfelder beim Erstellen neuer Veranstaltungen erforderlich sind.",
"requireCustomerName": "Kundenname erforderlich",
"requireCustomerNameHelp": "Kundenname muss für neue Veranstaltungen angegeben werden",
"requireCustomerEmail": "Kunden-E-Mail erforderlich",
"requireCustomerEmailHelp": "Kunden-E-Mail muss für neue Veranstaltungen angegeben werden",
"customerEmailWarning": "Erforderlich für das Versenden von Galerie-Einladungen",
"requireAdminEmail": "Admin-E-Mail erforderlich",
"requireAdminEmailHelp": "Admin-E-Mail muss für neue Veranstaltungen angegeben werden",
"adminEmailWarning": "Erforderlich für den Erhalt von Veranstaltungsbenachrichtigungen",
"saveSettings": "Veranstaltungseinstellungen speichern",
"noteTitle": "Hinweis",
"noteText": "Diese Einstellungen betreffen nur die Erstellung neuer Veranstaltungen. Bestehende Veranstaltungen sind nicht betroffen. Standardmäßig sind alle Felder erforderlich."
},
"imageSecurity": {
"title": "Bildschutz",
"saveSuccess": "Bildschutz-Einstellungen gespeichert",
"saveError": "Fehler beim Speichern der Einstellungen",
"loadError": "Fehler beim Laden der Bildschutz-Einstellungen",
"defaultProtection": "Standard-Schutzeinstellungen",
"defaultProtectionHelp": "Diese Einstellungen gelten für alle neuen Veranstaltungen. Einzelne Veranstaltungen können diese Standardwerte überschreiben.",
"protectionLevel": "Standard-Schutzstufe",
"imageQuality": "Standard-Bildqualität",
"fragmentationLevel": "Fragmentierungsstufe",
"enableDevtools": "DevTools-Erkennung standardmäßig aktivieren",
"enableCanvas": "Canvas-Rendering 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",
"requestsPer5Minutes": "Anfragen pro 5 Min.",
"requestsPerHour": "Anfragen pro Stunde",
"securityMonitoring": "Sicherheitsüberwachung",
"suspiciousThreshold": "Schwellenwert für verdächtige Aktivitäten",
"autoBlockThreshold": "Automatische Sperrschwelle",
"enableMonitoring": "Sicherheitsüberwachung aktivieren",
"blockSuspiciousIps": "Verdächtige IPs automatisch blockieren",
"logEvents": "Sicherheitsereignisse in Datenbank protokollieren",
"infoTitle": "Über Bildschutz",
"infoText": "Diese Schutzfunktionen helfen, gelegentliches Herunterladen und Kopieren zu verhindern, können aber nicht alle Methoden blockieren. Entschlossene Benutzer finden möglicherweise trotzdem Wege, Bilder zu erfassen. Erwägen Sie die Verwendung von Wasserzeichen und rechtlichen Vereinbarungen für umfassenden Schutz."
},
"categories": {
"title": "Kategorien",
"about": "Über Fotokategorien",
@@ -1209,7 +1255,31 @@
"customizeTheme": "Design anpassen",
"saveTheme": "Design speichern",
"previewLayout": "Vorschau-Layout",
"livePreview": "Live-Vorschau"
"livePreview": "Live-Vorschau",
"whiteLabel": "White Label",
"hidePoweredBy": "\"Powered by PicPeak\" Branding ausblenden",
"hidePoweredByHelp": "Entfernen Sie die PicPeak-Kennzeichnung aus Galerie-Fußzeilen für ein vollständig personalisiertes Erscheinungsbild",
"logoCustomization": "Logo-Anpassung",
"changeLogo": "Logo ändern",
"logoSizeSmall": "Klein (32px)",
"logoSizeMedium": "Mittel (48px)",
"logoSizeLarge": "Groß (64px)",
"logoSizeXLarge": "Extra Groß (96px)",
"logoSizeCustom": "Benutzerdefiniert",
"logoMaxHeight": "Maximale Höhe (Pixel)",
"logoMaxHeightHelp": "Legen Sie eine benutzerdefinierte maximale Höhe für das Logo fest (20-200 Pixel)",
"logoPosition": "Logo-Position im Header",
"positionLeft": "Links",
"positionCenter": "Mitte",
"positionRight": "Rechts",
"logoDisplayMode": "Anzeigemodus",
"logoOnly": "Nur Logo",
"textOnly": "Nur Firmenname",
"logoAndText": "Logo und Firmenname",
"showLogoInHeader": "Logo im Galerie-Header anzeigen",
"showLogoInHeaderHelp": "Logo in der Hauptkopfzeile anzeigen",
"showLogoInHero": "Logo im Hero-Bereich anzeigen",
"showLogoInHeroHelp": "Logo in Hero-Bereichen anzeigen (für Nicht-Raster-Layouts)"
},
"admin": {
"title": "Admin-Panel",
+29 -1
View File
@@ -452,6 +452,10 @@
"enableDevtoolsProtection": "Detect developer tools",
"useCanvasRendering": "Canvas rendering (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",
"protectionLevelEnhanced": "Enhanced - Print screen detection",
"protectionLevelMaximum": "Maximum - DevTools detection & canvas rendering",
"heroPhoto": "Hero Photo",
"heroPhotoHelp": "Select a featured photo for the hero gallery layout",
"selectHeroPhoto": "Select Hero Photo",
@@ -1014,7 +1018,31 @@
"customizeTheme": "Customize Theme",
"saveTheme": "Save Theme",
"previewLayout": "Preview Layout",
"livePreview": "Live Preview"
"livePreview": "Live Preview",
"whiteLabel": "White Label",
"hidePoweredBy": "Hide \"Powered by PicPeak\" branding",
"hidePoweredByHelp": "Remove the PicPeak attribution from gallery footers for a fully white-labeled experience",
"logoCustomization": "Logo Customization",
"changeLogo": "Change Logo",
"logoSizeSmall": "Small (32px)",
"logoSizeMedium": "Medium (48px)",
"logoSizeLarge": "Large (64px)",
"logoSizeXLarge": "Extra Large (96px)",
"logoSizeCustom": "Custom",
"logoMaxHeight": "Maximum Height (pixels)",
"logoMaxHeightHelp": "Set a custom maximum height for the logo (20-200 pixels)",
"logoPosition": "Logo Position in Header",
"positionLeft": "Left",
"positionCenter": "Center",
"positionRight": "Right",
"logoDisplayMode": "Display Mode",
"logoOnly": "Logo Only",
"textOnly": "Company Name Only",
"logoAndText": "Logo and Company Name",
"showLogoInHeader": "Show logo in gallery header",
"showLogoInHeaderHelp": "Display the logo in the main header bar",
"showLogoInHero": "Show logo in hero section",
"showLogoInHeroHelp": "Display the logo in hero sections (for non-grid layouts)"
},
"admin": {
"title": "Admin Panel",
+2 -2
View File
@@ -15,7 +15,7 @@ export const BrandingPage: React.FC = () => {
const [brandingSettings, setBrandingSettings] = useState<BrandingSettings>({
company_name: '',
company_tagline: '',
footer_text: '© 2024 Your Company. All rights reserved.',
footer_text: '',
support_email: '',
watermark_enabled: false,
watermark_position: 'bottom-right',
@@ -307,7 +307,7 @@ export const BrandingPage: React.FC = () => {
onChange={(e) => handleBrandingChange('footer_text', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
rows={2}
placeholder="© 2024 Your Company. All rights reserved."
placeholder={`© ${new Date().getFullYear()} Your Company. All rights reserved.`}
/>
</div>
</div>
+1 -1
View File
@@ -144,7 +144,7 @@ export const LegalPage: React.FC = () => {
</Link>
</div>
<p className="text-sm text-neutral-500 mt-4">
© 2024 PicPeak. All rights reserved.
© {new Date().getFullYear()} PicPeak. All rights reserved.
</p>
</div>
</footer>
+3 -3
View File
@@ -12,10 +12,10 @@ interface PhotoUrlOptions {
*/
export function getPhotoUrl({ slug, photo, watermarkEnabled = false, token }: PhotoUrlOptions): string {
if (watermarkEnabled && token) {
// Use the watermarked photo endpoint
return `/gallery/${slug}/photo/${photo.id}`;
// Use the watermarked photo endpoint (needs /api prefix)
return `/api/gallery/${slug}/photo/${photo.id}`;
}
// Use the static photo URL
return photo.url;
}