feat: add photo cap per event and Portuguese (pt-BR) locale

- Add photo_cap column to events table (migration 074) to limit photos per event
- Enforce photo cap in upload route, returning 400 when limit exceeded
- Pass photo_cap through all event CRUD routes and frontend forms
- Add complete Portuguese (pt-BR) translation (2300+ strings)
- Register pt locale in i18n config, language selector, date formatting
- Add photoCap/photoCapHelp translation keys to all locale files (en, de, ru, pt)
This commit is contained in:
Paul Nothaft
2026-03-16 17:22:54 +01:00
parent 6aceb40595
commit 1fa222e9c4
19 changed files with 2451 additions and 12 deletions
@@ -0,0 +1,13 @@
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function(knex) {
console.log('Running migration: 074_add_photo_cap');
await addColumnIfNotExists(knex, 'events', 'photo_cap', (table) => {
table.integer('photo_cap').nullable().defaultTo(null);
});
console.log('Migration 074_add_photo_cap completed');
};
exports.down = async function(knex) {
console.log('Rollback: 074_add_photo_cap');
};
+4 -2
View File
@@ -42,7 +42,8 @@ router.post('/', adminAuth, requirePermission('events.create'), [
color_theme = null, color_theme = null,
expiration_days = 30, expiration_days = 30,
allow_user_uploads = false, allow_user_uploads = false,
upload_category_id = null upload_category_id = null,
photo_cap = null
} = req.body; } = req.body;
// Validate password strength for gallery // Validate password strength for gallery
@@ -105,7 +106,8 @@ router.post('/', adminAuth, requirePermission('events.create'), [
expires_at: expires_at.toISOString(), expires_at: expires_at.toISOString(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
allow_user_uploads, allow_user_uploads,
upload_category_id upload_category_id,
photo_cap: photo_cap || null
}).returning('id'); }).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
+6 -2
View File
@@ -256,7 +256,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [
header_style = 'standard', header_style = 'standard',
hero_divider_style = 'wave', hero_divider_style = 'wave',
// Hero image anchor position (#162) // Hero image anchor position (#162)
hero_image_anchor = 'center' hero_image_anchor = 'center',
// Photo cap
photo_cap = null
} = req.body; } = req.body;
const customerName = getCustomerNameFromPayload(req.body); const customerName = getCustomerNameFromPayload(req.body);
@@ -416,7 +418,8 @@ router.post('/', adminAuth, requirePermission('events.create'), [
hero_logo_position: hero_logo_position || 'top', hero_logo_position: hero_logo_position || 'top',
header_style: effectiveHeaderStyle || 'standard', header_style: effectiveHeaderStyle || 'standard',
hero_divider_style: effectiveDividerStyle || 'wave', hero_divider_style: effectiveDividerStyle || 'wave',
hero_image_anchor: hero_image_anchor || 'center' hero_image_anchor: hero_image_anchor || 'center',
photo_cap: photo_cap || null
}).returning('id'); }).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
@@ -479,6 +482,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
customer_name: customerName, customer_name: customerName,
customer_email: customerEmail, customer_email: customerEmail,
require_password: requirePassword, require_password: requirePassword,
photo_cap: photo_cap || null,
share_link: shareUrl, share_link: shareUrl,
expires_at: expires_at ? expires_at.toISOString() : null, expires_at: expires_at ? expires_at.toISOString() : null,
created_at: new Date().toISOString() created_at: new Date().toISOString()
+23
View File
@@ -171,6 +171,29 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
return res.status(404).json({ error: 'Event not found' }); return res.status(404).json({ error: 'Event not found' });
} }
// Enforce photo cap if set
if (event.photo_cap && event.photo_cap > 0) {
const existingPhotoCount = await db('photos')
.where({ event_id: eventId })
.count('id as count')
.first();
const currentCount = parseInt(existingPhotoCount.count) || 0;
const newFilesCount = (req.files && req.files.length) || 0;
if (currentCount + newFilesCount > event.photo_cap) {
// Clean up temp files
if (req.tempUploadPath) {
try {
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
} catch (e) {
console.error('Failed to clean up temp path:', e);
}
}
return res.status(400).json({
error: `Photo cap exceeded. This event allows a maximum of ${event.photo_cap} photos. Currently ${currentCount} photos exist, and you are trying to upload ${newFilesCount} more.`
});
}
}
if (!req.files || req.files.length === 0) { if (!req.files || req.files.length === 0) {
console.error('No files in request. req.files:', req.files); console.error('No files in request. req.files:', req.files);
console.error('Request body keys:', Object.keys(req.body)); console.error('Request body keys:', Object.keys(req.body));
+4 -2
View File
@@ -280,7 +280,8 @@ router.post('/gallery/verify', [
expires_at: event.expires_at, expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads, allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id, upload_category_id: event.upload_category_id,
require_password: requiresPassword require_password: requiresPassword,
photo_cap: event.photo_cap
} }
}); });
} catch (error) { } catch (error) {
@@ -353,7 +354,8 @@ router.post('/gallery/share-login', [
expires_at: event.expires_at, expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads, allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id, upload_category_id: event.upload_category_id,
require_password: requiresPassword require_password: requiresPassword,
photo_cap: event.photo_cap
} }
}); });
} catch (error) { } catch (error) {
+6 -2
View File
@@ -138,7 +138,9 @@ const createEvent = async (eventData) => {
show_feedback_to_guests, show_feedback_to_guests,
// Upload settings // Upload settings
allow_user_uploads, allow_user_uploads,
upload_category_id upload_category_id,
// Photo cap
photo_cap
} = eventData; } = eventData;
const requirePassword = parseBooleanInput(require_password, true); const requirePassword = parseBooleanInput(require_password, true);
@@ -207,7 +209,9 @@ const createEvent = async (eventData) => {
show_feedback_to_guests: show_feedback_to_guests !== undefined ? formatBoolean(show_feedback_to_guests) : undefined, show_feedback_to_guests: show_feedback_to_guests !== undefined ? formatBoolean(show_feedback_to_guests) : undefined,
// Upload settings // Upload settings
allow_user_uploads: allow_user_uploads !== undefined ? formatBoolean(allow_user_uploads) : undefined, allow_user_uploads: allow_user_uploads !== undefined ? formatBoolean(allow_user_uploads) : undefined,
upload_category_id: upload_category_id || null upload_category_id: upload_category_id || null,
// Photo cap
photo_cap: photo_cap || null
}; };
// Remove undefined values // Remove undefined values
+2
View File
@@ -54,6 +54,8 @@ async function formatDate(date, language = 'en') {
let locale = dateConfig.locale || 'en-GB'; let locale = dateConfig.locale || 'en-GB';
if (language === 'de') { if (language === 'de') {
locale = 'de-DE'; locale = 'de-DE';
} else if (language === 'pt') {
locale = 'pt-BR';
} else if (language === 'en' && dateConfig.format === 'MM/DD/YYYY') { } else if (language === 'en' && dateConfig.format === 'MM/DD/YYYY') {
locale = 'en-US'; locale = 'en-US';
} }
@@ -29,10 +29,20 @@ const RUFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) =>
</svg> </svg>
); );
const PTBRFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
<svg className={className} viewBox="0 0 640 480" xmlns="http://www.w3.org/2000/svg">
<path fill="#009B3A" d="M0 0h640v480H0z"/>
<path fill="#FEDF00" d="M320 39.4 590.4 240 320 440.6 49.6 240z"/>
<circle fill="#002776" cx="320" cy="240" r="95"/>
<path fill="#FFF" d="M226.3 262.8c0-27 12.8-51 32.7-66.3a95.3 95.3 0 0 0-3.5 120.6c-17.8-14.8-29.2-37-29.2-54.3z" opacity=".5"/>
</svg>
);
const languages = [ const languages = [
{ code: 'en', name: 'English', Flag: GBFlag }, { code: 'en', name: 'English', Flag: GBFlag },
{ code: 'de', name: 'Deutsch', Flag: DEFlag }, { code: 'de', name: 'Deutsch', Flag: DEFlag },
{ code: 'ru', name: 'Русский', Flag: RUFlag }, { code: 'ru', name: 'Русский', Flag: RUFlag },
{ code: 'pt', name: 'Português', Flag: PTBRFlag },
]; ];
export const LanguageSelector: React.FC = () => { export const LanguageSelector: React.FC = () => {
@@ -246,6 +246,7 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
> >
<option value="en">English</option> <option value="en">English</option>
<option value="de">Deutsch</option> <option value="de">Deutsch</option>
<option value="pt">Português (Brasil)</option>
</select> </select>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1"> <p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.general.defaultLanguageHelp')} {t('settings.general.defaultLanguageHelp')}
+4 -2
View File
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns'; import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
import { de, enUS } from 'date-fns/locale'; import { de, enUS, ptBR } from 'date-fns/locale';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { publicSettingsService } from '../services/publicSettings.service'; import { publicSettingsService } from '../services/publicSettings.service';
@@ -24,7 +24,9 @@ export const useLocalizedDate = () => {
}); });
const getLocale = () => { const getLocale = () => {
return i18n.language === 'de' ? de : enUS; if (i18n.language === 'de') return de;
if (i18n.language === 'pt' || i18n.language === 'pt-BR') return ptBR;
return enUS;
}; };
const format = (date: Date | string, formatStr?: string) => { const format = (date: Date | string, formatStr?: string) => {
+4
View File
@@ -6,6 +6,7 @@ import HttpBackend from 'i18next-http-backend';
import enTranslations from './locales/en.json'; import enTranslations from './locales/en.json';
import deTranslations from './locales/de.json'; import deTranslations from './locales/de.json';
import ruTranslations from './locales/ru.json'; import ruTranslations from './locales/ru.json';
import ptTranslations from './locales/pt.json';
i18n i18n
.use(HttpBackend) .use(HttpBackend)
@@ -25,6 +26,9 @@ i18n
ru: { ru: {
translation: ruTranslations, translation: ruTranslations,
}, },
pt: {
translation: ptTranslations,
},
}, },
interpolation: { interpolation: {
+2
View File
@@ -835,6 +835,8 @@
"expirationWarning": "Gäste erhalten 7 Tage vor Ablauf eine Warn-E-Mail.", "expirationWarning": "Gäste erhalten 7 Tage vor Ablauf eine Warn-E-Mail.",
"noExpiration": "Kein Ablaufdatum", "noExpiration": "Kein Ablaufdatum",
"noExpirationHelp": "Diese Galerie bleibt aktiv, bis sie manuell archiviert wird.", "noExpirationHelp": "Diese Galerie bleibt aktiv, bis sie manuell archiviert wird.",
"photoCap": "Fotolimit",
"photoCapHelp": "Maximale Anzahl erlaubter Fotos. 0 = unbegrenzt",
"userUploads": "Benutzer-Upload-Einstellungen", "userUploads": "Benutzer-Upload-Einstellungen",
"allowUserUploads": "Gästen erlauben, Fotos hochzuladen", "allowUserUploads": "Gästen erlauben, Fotos hochzuladen",
"allowUserUploadsHelp": "Ermöglichen Sie Gästen, ihre eigenen Fotos in diese Galerie hochzuladen", "allowUserUploadsHelp": "Ermöglichen Sie Gästen, ihre eigenen Fotos in diese Galerie hochzuladen",
+2
View File
@@ -461,6 +461,8 @@
"expirationWarning": "Guests will receive a warning email 7 days before expiration.", "expirationWarning": "Guests will receive a warning email 7 days before expiration.",
"noExpiration": "No Expiration", "noExpiration": "No Expiration",
"noExpirationHelp": "This gallery will remain active until manually archived.", "noExpirationHelp": "This gallery will remain active until manually archived.",
"photoCap": "Photo Limit",
"photoCapHelp": "Maximum number of photos allowed. 0 = unlimited",
"userUploads": "User Upload Settings", "userUploads": "User Upload Settings",
"allowUserUploads": "Allow guests to upload photos", "allowUserUploads": "Allow guests to upload photos",
"allowUserUploadsHelp": "Enable guests to upload their own photos to this gallery", "allowUserUploadsHelp": "Enable guests to upload their own photos to this gallery",
File diff suppressed because it is too large Load Diff
+2
View File
@@ -455,6 +455,8 @@
"expirationWarning": "Гости получат предупреждение по email за 7 дней до истечения.", "expirationWarning": "Гости получат предупреждение по email за 7 дней до истечения.",
"noExpiration": "Без срока действия", "noExpiration": "Без срока действия",
"noExpirationHelp": "Эта галерея будет активна до ручного архивирования.", "noExpirationHelp": "Эта галерея будет активна до ручного архивирования.",
"photoCap": "Лимит фото",
"photoCapHelp": "Максимальное количество фотографий. 0 = без ограничений",
"userUploads": "Настройки загрузки пользователями", "userUploads": "Настройки загрузки пользователями",
"allowUserUploads": "Разрешить гостям загружать фото", "allowUserUploads": "Разрешить гостям загружать фото",
"allowUserUploadsHelp": "Разрешить гостям загружать собственные фотографии в эту галерею", "allowUserUploadsHelp": "Разрешить гостям загружать собственные фотографии в эту галерею",
+26 -1
View File
@@ -8,7 +8,8 @@ import {
ArrowLeft, ArrowLeft,
Palette, Palette,
Eye, Eye,
EyeOff EyeOff,
Image
} from 'lucide-react'; } from 'lucide-react';
import { addDays } from 'date-fns'; import { addDays } from 'date-fns';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -44,6 +45,7 @@ interface FormData {
allow_user_uploads: boolean; allow_user_uploads: boolean;
upload_category_id: number | null; upload_category_id: number | null;
css_template_id: number | null; css_template_id: number | null;
photo_cap: number;
feedback_settings: { feedback_settings: {
feedback_enabled: boolean; feedback_enabled: boolean;
allow_ratings: boolean; allow_ratings: boolean;
@@ -98,6 +100,7 @@ export const CreateEventPage: React.FC = () => {
allow_user_uploads: false, allow_user_uploads: false,
upload_category_id: null, upload_category_id: null,
css_template_id: null, css_template_id: null,
photo_cap: 0,
feedback_settings: { feedback_settings: {
feedback_enabled: false, feedback_enabled: false,
allow_ratings: true, allow_ratings: true,
@@ -298,6 +301,7 @@ export const CreateEventPage: React.FC = () => {
allow_user_uploads: formData.allow_user_uploads, allow_user_uploads: formData.allow_user_uploads,
upload_category_id: formData.upload_category_id, upload_category_id: formData.upload_category_id,
css_template_id: formData.css_template_id, css_template_id: formData.css_template_id,
photo_cap: formData.photo_cap > 0 ? formData.photo_cap : null,
feedback_enabled: feedbackSettings.feedback_enabled, feedback_enabled: feedbackSettings.feedback_enabled,
allow_ratings: feedbackSettings.allow_ratings, allow_ratings: feedbackSettings.allow_ratings,
allow_likes: feedbackSettings.allow_likes, allow_likes: feedbackSettings.allow_likes,
@@ -717,6 +721,27 @@ export const CreateEventPage: React.FC = () => {
</div> </div>
)} )}
{/* Photo Cap */}
<div className="pt-4 border-t border-neutral-200 dark:border-neutral-700">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('events.photoCap', 'Photo Limit')}
</label>
<div className="flex items-center gap-2">
<div className="w-32">
<Input
type="number"
value={formData.photo_cap}
onChange={(e) => setFormData({ ...formData, photo_cap: parseInt(e.target.value) || 0 })}
min={0}
leftIcon={<Image className="w-5 h-5" />}
/>
</div>
<span className="text-sm text-neutral-600 dark:text-neutral-400">
{t('events.photoCapHelp', 'Maximum number of photos allowed. 0 = unlimited')}
</span>
</div>
</div>
{/* User Upload Settings */} {/* User Upload Settings */}
<div className="pt-4 border-t border-neutral-200 dark:border-neutral-700"> <div className="pt-4 border-t border-neutral-200 dark:border-neutral-700">
<label className="flex items-center gap-3"> <label className="flex items-center gap-3">
@@ -172,6 +172,8 @@ export const EventDetailsPage: React.FC = () => {
hero_logo_position: 'top' | 'center' | 'bottom'; hero_logo_position: 'top' | 'center' | 'bottom';
// Hero image anchor position (#162) keyword or "X% Y%" focal point // Hero image anchor position (#162) keyword or "X% Y%" focal point
hero_image_anchor: string; hero_image_anchor: string;
// Photo cap
photo_cap: number;
}; };
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
@@ -202,6 +204,8 @@ export const EventDetailsPage: React.FC = () => {
hero_logo_position: 'top', hero_logo_position: 'top',
// Hero image anchor position (#162) // Hero image anchor position (#162)
hero_image_anchor: 'center', hero_image_anchor: 'center',
// Photo cap
photo_cap: 0,
}); });
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({ const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
feedback_enabled: false, feedback_enabled: false,
@@ -417,6 +421,8 @@ export const EventDetailsPage: React.FC = () => {
hero_logo_position: event.hero_logo_position || 'top', hero_logo_position: event.hero_logo_position || 'top',
// Hero image anchor position (#162) // Hero image anchor position (#162)
hero_image_anchor: event.hero_image_anchor || 'center', hero_image_anchor: event.hero_image_anchor || 'center',
// Photo cap
photo_cap: event.photo_cap || 0,
}); });
setShowNewPassword(false); setShowNewPassword(false);
@@ -550,6 +556,8 @@ export const EventDetailsPage: React.FC = () => {
hero_logo_position: editForm.hero_logo_position, hero_logo_position: editForm.hero_logo_position,
// Hero image anchor position (#162) // Hero image anchor position (#162)
hero_image_anchor: editForm.hero_image_anchor, hero_image_anchor: editForm.hero_image_anchor,
// Photo cap
photo_cap: editForm.photo_cap > 0 ? editForm.photo_cap : null,
// Header style settings (decoupled from layout, #158) // Header style settings (decoupled from layout, #158)
header_style: currentTheme?.headerStyle || 'standard', header_style: currentTheme?.headerStyle || 'standard',
hero_divider_style: currentTheme?.heroDividerStyle || 'wave', hero_divider_style: currentTheme?.heroDividerStyle || 'wave',
@@ -1026,6 +1034,25 @@ export const EventDetailsPage: React.FC = () => {
</div> </div>
)} )}
{/* Photo Cap */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.photoCap', 'Photo Limit')}
</label>
<div className="flex items-center gap-2">
<input
type="number"
value={editForm.photo_cap}
onChange={(e) => setEditForm(prev => ({ ...prev, photo_cap: parseInt(e.target.value) || 0 }))}
min={0}
className="w-24 px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{t('events.photoCapHelp', 'Maximum number of photos allowed. 0 = unlimited')}
</span>
</div>
</div>
<div> <div>
<label className="flex items-center"> <label className="flex items-center">
<input <input
+2
View File
@@ -39,6 +39,7 @@ interface CreateEventData {
require_name_email?: boolean; require_name_email?: boolean;
moderate_comments?: boolean; moderate_comments?: boolean;
show_feedback_to_guests?: boolean; show_feedback_to_guests?: boolean;
photo_cap?: number | null;
} }
interface UpdateEventData { interface UpdateEventData {
@@ -58,6 +59,7 @@ interface UpdateEventData {
hero_photo_id?: number | null; hero_photo_id?: number | null;
source_mode?: 'managed' | 'reference'; source_mode?: 'managed' | 'reference';
external_path?: string | null; external_path?: string | null;
photo_cap?: number | null;
} }
interface EventsListResponse { interface EventsListResponse {
+3
View File
@@ -53,6 +53,8 @@ export interface Event {
hero_image_anchor?: string; hero_image_anchor?: string;
// CSS Template // CSS Template
css_template_id?: number | null; css_template_id?: number | null;
// Photo cap
photo_cap?: number | null;
} }
export interface GalleryInfo { export interface GalleryInfo {
@@ -202,6 +204,7 @@ export interface GalleryAuthResponse {
allow_user_uploads?: boolean; allow_user_uploads?: boolean;
upload_category_id?: number | null; upload_category_id?: number | null;
require_password?: boolean; require_password?: boolean;
photo_cap?: number | null;
}; };
} }