Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41f80fc898 | |||
| 0f7551ab5b | |||
| 7c58749806 | |||
| 050ed37819 | |||
| 83a4344a01 | |||
| 9b50f3d6b7 | |||
| e945bc9413 | |||
| 3b720ed56e | |||
| ce8587b24d | |||
| fe772b52d6 | |||
| f29b77998b | |||
| f843e4c25c | |||
| ea20446a79 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "2.3.1"
|
||||
".": "2.3.4"
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user